janela 0.2.1 → 0.4.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 +63 -1
- data/LICENSE.txt +1 -1
- data/README.md +244 -23
- data/UPGRADING.md +172 -0
- data/app/assets/javascripts/janela/chart_controller.js +23 -10
- data/app/assets/javascripts/janela/frame_controller.js +188 -0
- data/app/assets/javascripts/janela/vitral_controller.js +263 -0
- data/app/assets/stylesheets/janela.css +164 -0
- data/app/assets/stylesheets/vitral.css +343 -0
- data/app/controllers/janela/application_controller.rb +25 -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} +26 -9
- data/app/models/janela/frame.rb +28 -0
- data/app/models/janela/pane.rb +102 -99
- data/app/models/janela/query.rb +162 -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 +5 -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 +22 -5
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +65 -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/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/INDEX.md +25 -7
- data/docs/multi-tenancy.md +175 -0
- data/docs/naming.md +172 -0
- data/lib/janela/definition.rb +5 -5
- data/lib/janela/doctor.rb +219 -0
- data/lib/janela/engine.rb +24 -2
- data/lib/janela/host_routes.rb +31 -0
- data/lib/janela/measure.rb +65 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +24 -0
- data/lib/tasks/janela.rake +6 -0
- metadata +57 -4
- data/app/assets/javascripts/janela/dashboard_controller.js +0 -58
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?
|
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
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# Reads a host application and reports what it still has to do. Only ever
|
|
3
|
+
# reads and reports: the checks are the install and upgrade traps that
|
|
4
|
+
# experience says actually break a host (ADR 015).
|
|
5
|
+
class Doctor
|
|
6
|
+
# code is the check that produced the finding, set by the runner rather
|
|
7
|
+
# than by each check, so the two can never drift apart.
|
|
8
|
+
Finding = Struct.new(:severity, :summary, :detail, :code, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Run in this order, and each one names the finding it produces: a host
|
|
11
|
+
# silences a check by that name (ADR 021).
|
|
12
|
+
CHECKS = %i[stale_identifiers unmounted_engine unmigrated_tables unregistered_controllers
|
|
13
|
+
through_dimensions_without_an_allowlist frames_nobody_will_own
|
|
14
|
+
unauthenticated_endpoints].freeze
|
|
15
|
+
|
|
16
|
+
# Identifiers a previous version of Janela used, and what replaced them.
|
|
17
|
+
RENAMED = {
|
|
18
|
+
"janela--dashboard" => "janela--frame",
|
|
19
|
+
"janela_dashboard" => "janela_frame",
|
|
20
|
+
"janela/dashboard_controller" => "janela/frame_controller",
|
|
21
|
+
"janela/dashboard_controller.js" => "janela/frame_controller.js",
|
|
22
|
+
"Janela::DashboardHelper" => "Janela::FramesHelper",
|
|
23
|
+
"Janela::PanesController" => "Janela::QueriesController",
|
|
24
|
+
"Janela::SnapshotPanesController" => "Janela::SnapshotQueriesController"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
SEARCHED = %w[app config lib].freeze
|
|
28
|
+
READABLE = %w[.rb .erb .js .erb.html .html.erb .haml .slim .yml].freeze
|
|
29
|
+
|
|
30
|
+
def initialize(root)
|
|
31
|
+
@root = Pathname.new(root)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def report(out)
|
|
35
|
+
findings, silenced = all_findings.partition { |finding| !silenced?(finding) }
|
|
36
|
+
|
|
37
|
+
if findings.empty?
|
|
38
|
+
out.puts "Janela: nothing to fix."
|
|
39
|
+
else
|
|
40
|
+
out.puts "Janela found #{findings.size} #{'thing'.pluralize(findings.size)} to look at."
|
|
41
|
+
findings.each do |finding|
|
|
42
|
+
out.puts
|
|
43
|
+
out.puts "#{finding.severity.to_s.upcase} (#{finding.code}): #{finding.summary}"
|
|
44
|
+
out.puts finding.detail
|
|
45
|
+
end
|
|
46
|
+
out.puts
|
|
47
|
+
out.puts "Silence one you have judged a false alarm, in config/initializers/janela.rb:"
|
|
48
|
+
out.puts " Janela.silenced_checks = %w[#{findings.first.code}]"
|
|
49
|
+
out.puts
|
|
50
|
+
out.puts "Steps for a version upgrade are in UPGRADING.md."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Said out loud every run, because a silence nobody remembers is how a
|
|
54
|
+
# real finding goes unread.
|
|
55
|
+
out.puts "Silenced: #{silenced.map(&:code).uniq.join(', ')}." if silenced.any?
|
|
56
|
+
findings.none? { |finding| finding.severity == :error }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check
|
|
60
|
+
all_findings.reject { |finding| silenced?(finding) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
def all_findings
|
|
65
|
+
@all_findings ||= CHECKS.flat_map { |name| findings_from(name) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Struct responds to to_a, so a single finding is wrapped by hand rather
|
|
69
|
+
# than with Array(), which would take it apart into its members.
|
|
70
|
+
def findings_from(name)
|
|
71
|
+
found = send(name)
|
|
72
|
+
found = [ found ] unless found.is_a?(Array)
|
|
73
|
+
found.compact.each { |finding| finding.code = name.to_s.dasherize }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def silenced?(finding)
|
|
77
|
+
Janela.silenced_checks.map(&:to_s).include?(finding.code)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stale_identifiers
|
|
81
|
+
RENAMED.filter_map do |old, new|
|
|
82
|
+
files = source_files.select { |file| file.read.include?(old) }
|
|
83
|
+
next if files.empty?
|
|
84
|
+
|
|
85
|
+
Finding.new(severity: :error,
|
|
86
|
+
summary: "#{old} is now #{new}",
|
|
87
|
+
detail: files.map { |file| " #{file.relative_path_from(@root)}" }.join("\n"))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unmounted_engine
|
|
92
|
+
return if engine_mounted?
|
|
93
|
+
|
|
94
|
+
Finding.new(severity: :error,
|
|
95
|
+
summary: "Janela::Engine is not mounted",
|
|
96
|
+
detail: " Add to config/routes.rb, at whatever path suits you:\n" \
|
|
97
|
+
" mount Janela::Engine => \"/insights\"")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The mount root serves an index of frames, so a host that upgrades
|
|
101
|
+
# without running the migrations finds an exception where its dashboards
|
|
102
|
+
# were. Only a host that mounts the engine needs the tables at all.
|
|
103
|
+
def unmigrated_tables
|
|
104
|
+
return unless engine_mounted?
|
|
105
|
+
|
|
106
|
+
missing = [ Janela::Frame, Janela::Pane, Janela::Snapshot ].reject(&:table_exists?).map(&:table_name)
|
|
107
|
+
return if missing.empty?
|
|
108
|
+
|
|
109
|
+
Finding.new(severity: :error,
|
|
110
|
+
summary: "#{missing.to_sentence} #{missing.one? ? 'is' : 'are'} missing",
|
|
111
|
+
detail: " Janela's own pages read these the moment the engine is mounted. Run:\n" \
|
|
112
|
+
" bin/rails janela:install:migrations\n" \
|
|
113
|
+
" bin/rails db:migrate")
|
|
114
|
+
rescue StandardError
|
|
115
|
+
nil # no database to ask yet
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def engine_mounted?
|
|
119
|
+
Rails.application.routes.routes.any? { |route| route.app.respond_to?(:app) && route.app.app == Janela::Engine }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unregistered_controllers
|
|
123
|
+
registered = source_files.any? { |file| file.read.include?("janela--frame") }
|
|
124
|
+
return if registered
|
|
125
|
+
|
|
126
|
+
Finding.new(severity: :error,
|
|
127
|
+
summary: "Janela's Stimulus controllers are not registered anywhere",
|
|
128
|
+
detail: " Without them a dashboard renders but nothing cross-filters, which\n" \
|
|
129
|
+
" looks like nothing happening at all. Register both:\n" \
|
|
130
|
+
" application.register(\"janela--frame\", JanelaFrameController)\n" \
|
|
131
|
+
" application.register(\"janela--chart\", JanelaChartController)")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Ransack's allowlist is per class, so a dimension read through an
|
|
135
|
+
# association needs the associated model to allow the attribute too.
|
|
136
|
+
def through_dimensions_without_an_allowlist
|
|
137
|
+
janela_models.flat_map do |model|
|
|
138
|
+
model.janela.dimensions.values.select(&:through).filter_map do |dimension|
|
|
139
|
+
association = model.reflect_on_association(dimension.through)
|
|
140
|
+
next unless association
|
|
141
|
+
|
|
142
|
+
allowed = association.klass.ransackable_attributes.map(&:to_s)
|
|
143
|
+
next if allowed.include?(dimension.column.to_s)
|
|
144
|
+
|
|
145
|
+
Finding.new(severity: :error,
|
|
146
|
+
summary: "#{association.klass} does not allow filtering on #{dimension.column}",
|
|
147
|
+
detail: " #{model}'s #{dimension.name.inspect} dimension reads it through " \
|
|
148
|
+
"#{dimension.through.inspect}, and Ransack's allowlist is per class. Add to " \
|
|
149
|
+
"#{association.klass}:\n" \
|
|
150
|
+
" def self.ransackable_attributes(_auth_object = nil) = " \
|
|
151
|
+
"%w[#{(allowed + [ dimension.column.to_s ]).uniq.join(' ')}]")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A host whose policy filters frames by owner, but which never tells
|
|
157
|
+
# Janela what owns a new one, creates frames its own scope then hides.
|
|
158
|
+
# The failure is silent, and a typo in the method name looks the same as
|
|
159
|
+
# not defining it, which is the cost of asking by duck typing (ADR 019).
|
|
160
|
+
def frames_nobody_will_own
|
|
161
|
+
parent = Janela.parent_controller.safe_constantize
|
|
162
|
+
return unless parent&.private_method_defined?(:policy_scope) || parent&.method_defined?(:policy_scope)
|
|
163
|
+
return if parent.private_method_defined?(:janela_frame_owner) || parent.method_defined?(:janela_frame_owner)
|
|
164
|
+
return unless Janela::Frame.table_exists? && scope_filters_frames_by_owner?(parent)
|
|
165
|
+
|
|
166
|
+
Finding.new(severity: :error,
|
|
167
|
+
summary: "#{parent} scopes frames by owner but defines no janela_frame_owner",
|
|
168
|
+
detail: " A frame created through Janela's own form would have no owner, and your\n" \
|
|
169
|
+
" own scope would then hide it. Define this on #{parent}:\n" \
|
|
170
|
+
" def janela_frame_owner\n" \
|
|
171
|
+
" Current.account # whatever your policy scope filters frames by\n" \
|
|
172
|
+
" end")
|
|
173
|
+
rescue StandardError
|
|
174
|
+
nil # no database or no policy to ask; nothing can be concluded
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Asking the policy rather than reading its source: a scope that narrows
|
|
178
|
+
# frames is one that will hide an unowned one.
|
|
179
|
+
def scope_filters_frames_by_owner?(parent)
|
|
180
|
+
scope = parent.allocate.send(:policy_scope, Janela::Frame)
|
|
181
|
+
scope.to_sql.include?("owner")
|
|
182
|
+
rescue StandardError
|
|
183
|
+
false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Whether an endpoint is public depends on what the host's controller
|
|
187
|
+
# does, which cannot be determined by reading, so this observes and says
|
|
188
|
+
# so rather than declaring anything safe.
|
|
189
|
+
def unauthenticated_endpoints
|
|
190
|
+
parent = Janela.parent_controller.safe_constantize
|
|
191
|
+
return unless parent
|
|
192
|
+
|
|
193
|
+
filters = parent._process_action_callbacks.map(&:filter).map(&:to_s)
|
|
194
|
+
return if filters.any? { |filter| filter.match?(/authenticat|require_user|require_login|login_required/) }
|
|
195
|
+
|
|
196
|
+
Finding.new(severity: :warning,
|
|
197
|
+
summary: "no authentication filter found on #{parent}",
|
|
198
|
+
detail: " Janela's controllers inherit #{parent}, so they are as public as it is,\n" \
|
|
199
|
+
" and this check only reads its filters: if you authenticate another way\n" \
|
|
200
|
+
" this is a false alarm. Otherwise see Securing dashboards in the README.")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def janela_models
|
|
204
|
+
Rails.application.eager_load!
|
|
205
|
+
ActiveRecord::Base.descendants.select { |model| model.respond_to?(:janela) && model.janela }
|
|
206
|
+
rescue StandardError
|
|
207
|
+
[]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def source_files
|
|
211
|
+
@source_files ||= SEARCHED.flat_map do |directory|
|
|
212
|
+
path = @root.join(directory)
|
|
213
|
+
next [] unless path.directory?
|
|
214
|
+
|
|
215
|
+
path.glob("**/*").select { |file| file.file? && READABLE.any? { |extension| file.to_s.end_with?(extension) } }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
data/lib/janela/engine.rb
CHANGED
|
@@ -8,10 +8,32 @@ module Janela
|
|
|
8
8
|
ActiveSupport.on_load(:active_record) { extend Janela::Model }
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
# isolate_namespace keeps engine helpers out of the host, but the
|
|
11
|
+
# isolate_namespace keeps engine helpers out of the host, but the frame
|
|
12
12
|
# helpers are the engine's public API and belong in the host's views.
|
|
13
13
|
initializer "janela.helpers" do
|
|
14
|
-
ActiveSupport.on_load(:action_view) { include Janela::
|
|
14
|
+
ActiveSupport.on_load(:action_view) { include Janela::FramesHelper }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# janela_frame renders the engine's own partials from a host's page, and
|
|
18
|
+
# an engine's views are otherwise only on the lookup path of its own
|
|
19
|
+
# controllers.
|
|
20
|
+
initializer "janela.views" do
|
|
21
|
+
ActiveSupport.on_load(:action_controller) { append_view_path Janela::Engine.root.join("app/views") }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Janela's own layout links janela.css, and a host on Sprockets serves it
|
|
25
|
+
# in production only if something declared it.
|
|
26
|
+
initializer "janela.assets" do |app|
|
|
27
|
+
if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:precompile)
|
|
28
|
+
app.config.assets.precompile << "janela.css"
|
|
29
|
+
app.config.assets.precompile << "vitral.css"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A host's route names are only known once its routes are drawn, which is
|
|
34
|
+
# lazy and happens again on every reload in development.
|
|
35
|
+
initializer "janela.host_routes" do |app|
|
|
36
|
+
app.config.after_routes_loaded { Janela::HostRoutes.define! }
|
|
15
37
|
end
|
|
16
38
|
|
|
17
39
|
initializer "janela.importmap", before: "importmap" do |app|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# Route helpers the host application has and the engine does not, forwarded
|
|
3
|
+
# to the host.
|
|
4
|
+
#
|
|
5
|
+
# isolate_namespace points every route helper inside Janela's controllers at
|
|
6
|
+
# the engine's own routes, and a host's ApplicationController runs there,
|
|
7
|
+
# because Janela's controllers inherit it. So an authentication redirect, a
|
|
8
|
+
# rescue_from or an after_action that names one of the host's own routes
|
|
9
|
+
# raises where it would work anywhere else in the application (ADR 022).
|
|
10
|
+
#
|
|
11
|
+
# Only names the engine does not define are forwarded, so the engine's own
|
|
12
|
+
# routes can never be shadowed by a host's. main_app stays the unambiguous
|
|
13
|
+
# way to say either.
|
|
14
|
+
module HostRoutes
|
|
15
|
+
def self.define!(host: Rails.application.routes, engine: Janela::Engine.routes)
|
|
16
|
+
# Routes reload in development, so a name that has gone is removed
|
|
17
|
+
# rather than left behind pointing at nothing.
|
|
18
|
+
instance_methods(false).each { |method| remove_method(method) }
|
|
19
|
+
|
|
20
|
+
forwarded(host: host, engine: engine).each do |name|
|
|
21
|
+
define_method(name) do |*args, **options, &block|
|
|
22
|
+
main_app.public_send(name, *args, **options, &block)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.forwarded(host: Rails.application.routes, engine: Janela::Engine.routes)
|
|
28
|
+
host.named_routes.helper_names - engine.named_routes.helper_names
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/janela/measure.rb
CHANGED
|
@@ -4,22 +4,34 @@ module Janela
|
|
|
4
4
|
# Aggregates whose answer is a number, so a boolean column would have its
|
|
5
5
|
# result cast back to true or false by ActiveRecord.
|
|
6
6
|
NUMERIC = %i[sum average].freeze
|
|
7
|
+
# Declared beside the aggregate, so they are taken out before the one
|
|
8
|
+
# remaining option is read as the aggregate.
|
|
9
|
+
FORMATS = %i[precision prefix suffix].freeze
|
|
10
|
+
# Where neither the aggregate nor the column says how many decimal places
|
|
11
|
+
# the measure means, two: enough for a rate or a currency, and short of
|
|
12
|
+
# the noise a float carries (issue #25).
|
|
13
|
+
FALLBACK_PRECISION = 2
|
|
7
14
|
|
|
8
|
-
attr_reader :name, :aggregate, :column
|
|
15
|
+
attr_reader :name, :aggregate, :column, :prefix, :suffix
|
|
9
16
|
|
|
10
|
-
def self.build(name, **options)
|
|
17
|
+
def self.build(name, model: nil, **options)
|
|
18
|
+
format = options.extract!(*FORMATS)
|
|
11
19
|
aggregate, column = options.first
|
|
12
20
|
unless options.size == 1 && AGGREGATES.include?(aggregate)
|
|
13
21
|
raise Error, "measure #{name.inspect} needs exactly one of #{AGGREGATES.join(', ')}"
|
|
14
22
|
end
|
|
15
23
|
|
|
16
|
-
new(name, aggregate, column == true ? nil : column)
|
|
24
|
+
new(name, aggregate, column == true ? nil : column, model: model, **format)
|
|
17
25
|
end
|
|
18
26
|
|
|
19
|
-
def initialize(name, aggregate, column)
|
|
27
|
+
def initialize(name, aggregate, column, model: nil, precision: nil, prefix: nil, suffix: nil)
|
|
20
28
|
@name = name
|
|
21
29
|
@aggregate = aggregate
|
|
22
30
|
@column = column
|
|
31
|
+
@model = model
|
|
32
|
+
@precision = precision!(precision)
|
|
33
|
+
@prefix = prefix
|
|
34
|
+
@suffix = suffix
|
|
23
35
|
end
|
|
24
36
|
|
|
25
37
|
def apply(relation)
|
|
@@ -31,5 +43,54 @@ module Janela
|
|
|
31
43
|
def sql_alias
|
|
32
44
|
"#{aggregate}_#{column || 'all'}"
|
|
33
45
|
end
|
|
46
|
+
|
|
47
|
+
# How many decimal places this measure means. Counting rows has none, and
|
|
48
|
+
# a decimal column already declares its own scale, so money and counts
|
|
49
|
+
# read correctly with nothing declared at all (ADR 020).
|
|
50
|
+
def precision
|
|
51
|
+
@precision || column_scale || FALLBACK_PRECISION
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Rendering, never rounding: the number itself reaches a snapshot and a
|
|
55
|
+
# comparison at full precision, so a stored pane reads back under whatever
|
|
56
|
+
# format is declared later (ADR 009). Anything that is not a number is
|
|
57
|
+
# left alone, since minimum of a string is still that string.
|
|
58
|
+
def format(value)
|
|
59
|
+
return "" if value.nil?
|
|
60
|
+
return value.to_s unless value.is_a?(Numeric)
|
|
61
|
+
|
|
62
|
+
"#{prefix}#{rounded(value)}#{suffix}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
def rounded(value)
|
|
67
|
+
ActiveSupport::NumberHelper.number_to_rounded(value, precision: precision,
|
|
68
|
+
delimiter: I18n.t("number.format.delimiter", default: ","))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Asking the schema rather than the value: one bucket of a measure can
|
|
72
|
+
# land on a whole number without the measure being a whole number.
|
|
73
|
+
def column_scale
|
|
74
|
+
return 0 if aggregate == :count || column.nil?
|
|
75
|
+
|
|
76
|
+
type = @model&.type_for_attribute(column)
|
|
77
|
+
return if type.nil?
|
|
78
|
+
return 0 if type.type == :integer && aggregate != :average
|
|
79
|
+
|
|
80
|
+
type.scale if type.respond_to?(:scale)
|
|
81
|
+
rescue ActiveRecord::ActiveRecordError
|
|
82
|
+
nil # no database to ask yet
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def precision!(value)
|
|
86
|
+
return if value.nil?
|
|
87
|
+
|
|
88
|
+
places = Integer(value, exception: false)
|
|
89
|
+
unless places&.between?(0, 10)
|
|
90
|
+
raise Error, "measure #{name.inspect} takes a precision of 0 to 10 decimal places, not #{value.inspect}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
places
|
|
94
|
+
end
|
|
34
95
|
end
|
|
35
96
|
end
|
data/lib/janela/version.rb
CHANGED
data/lib/janela.rb
CHANGED
|
@@ -9,6 +9,8 @@ require "janela/model"
|
|
|
9
9
|
require "janela/definition"
|
|
10
10
|
require "janela/measure"
|
|
11
11
|
require "janela/dimension"
|
|
12
|
+
require "janela/doctor"
|
|
13
|
+
require "janela/host_routes"
|
|
12
14
|
|
|
13
15
|
module Janela
|
|
14
16
|
class Error < StandardError; end
|
|
@@ -23,6 +25,18 @@ module Janela
|
|
|
23
25
|
# and authorisation apply to dashboards with no configuration.
|
|
24
26
|
mattr_accessor :parent_controller, default: "ApplicationController"
|
|
25
27
|
|
|
28
|
+
# The stylesheet Janela's own pages load on top of janela.css. Nil means the
|
|
29
|
+
# structural one only, which is what a host that has its own look wants. The
|
|
30
|
+
# gem ships "vitral". A host's own pages are untouched either way: they load
|
|
31
|
+
# whatever that host's layout says (ADR 023).
|
|
32
|
+
mattr_accessor :theme, default: nil
|
|
33
|
+
|
|
34
|
+
# Checks janela:doctor should not report, by the name it prints beside each
|
|
35
|
+
# finding. A check that is a false alarm for one application stays a false
|
|
36
|
+
# alarm, and that is a judgement made once at boot, which is what a setting
|
|
37
|
+
# is for (ADR 021).
|
|
38
|
+
mattr_accessor :silenced_checks, default: []
|
|
39
|
+
|
|
26
40
|
# Only models that declare a janela block are addressable over HTTP, keyed by
|
|
27
41
|
# the route key that appears in pane URLs (orders, sales_orders). Names are
|
|
28
42
|
# stored rather than classes so a reloaded model leaves nothing stale behind.
|
|
@@ -34,6 +48,16 @@ module Janela
|
|
|
34
48
|
registry[model.model_name.route_key] = model.name
|
|
35
49
|
end
|
|
36
50
|
|
|
51
|
+
# Every model that declares a janela block, for a form that offers a choice
|
|
52
|
+
# of them. Eager loading first, because a model nobody has referenced yet has
|
|
53
|
+
# not registered. A name that no longer resolves is left out rather than
|
|
54
|
+
# raised on: a model renamed or deleted in development leaves its old key
|
|
55
|
+
# here until a restart, and a form offering it would fail to draw at all.
|
|
56
|
+
def self.definitions
|
|
57
|
+
Rails.application.eager_load!
|
|
58
|
+
registry.sort.filter_map { |_route_key, class_name| class_name.safe_constantize&.janela }
|
|
59
|
+
end
|
|
60
|
+
|
|
37
61
|
def self.definition!(route_key)
|
|
38
62
|
# In development a model is only registered once autoloaded, so a cold
|
|
39
63
|
# lookup loads the app rather than constantizing an unvetted parameter.
|