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
data/docs/theming.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
---
|
|
2
|
+
Topics: styling, theming, css, host-integration
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Theming Janela
|
|
6
|
+
|
|
7
|
+
Janela publishes the hooks; a theme supplies the taste. This page is the
|
|
8
|
+
contract: the class names and custom properties the engine renders, which
|
|
9
|
+
your own stylesheet or somebody else's theme may target, and which will
|
|
10
|
+
not change without an entry in `UPGRADING.md` (ADR 036).
|
|
11
|
+
|
|
12
|
+
Two stylesheets ship in the gem.
|
|
13
|
+
|
|
14
|
+
```erb
|
|
15
|
+
<%= stylesheet_link_tag "janela" %> <%# the hooks, and enough style to be legible %>
|
|
16
|
+
<%= stylesheet_link_tag "vitral" %> <%# optional: one theme, stained glass %>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`janela.css` is not a look. It is the grid, the pane's own markup and
|
|
20
|
+
enough base styling that a table does not run its label into its number.
|
|
21
|
+
Everything decorative is a theme's, and vitral is one theme rather than
|
|
22
|
+
the theme.
|
|
23
|
+
|
|
24
|
+
## The contract
|
|
25
|
+
|
|
26
|
+
### Custom properties
|
|
27
|
+
|
|
28
|
+
Three, and setting them moves everything that depends on them.
|
|
29
|
+
|
|
30
|
+
| Property | Default | What it does |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `--janela-space` | `0.25rem` | The base unit of the whole spacing scale. Every gap and padding is a multiple of it. |
|
|
33
|
+
| `--janela-line` | `rgba(128, 128, 128, 0.3)` | Rules between rows, borders on cards and fields. |
|
|
34
|
+
| `--janela-accent` | `rgb(54, 162, 235)` | A selected value, a hovered card. |
|
|
35
|
+
|
|
36
|
+
```css
|
|
37
|
+
:root {
|
|
38
|
+
--janela-space: 0.3rem;
|
|
39
|
+
--janela-accent: #7c3aed;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### The grid
|
|
44
|
+
|
|
45
|
+
A frame's stored integers choose these. Nothing an analyst types reaches
|
|
46
|
+
CSS as a length: the number selects a rule that is already written
|
|
47
|
+
(ADR 016).
|
|
48
|
+
|
|
49
|
+
| Class | Range |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `janela-frame` | the grid container itself |
|
|
52
|
+
| `janela-cols-N` | 1 to 12 |
|
|
53
|
+
| `janela-gap-N` | 0 to 8, multiplied by `--janela-space` |
|
|
54
|
+
| `janela-span-N` | 1 to 12, on a pane |
|
|
55
|
+
|
|
56
|
+
Below `40rem` the grid collapses to one column. That is in the
|
|
57
|
+
stylesheet rather than in the data, because a dashboard nobody can read
|
|
58
|
+
on a phone is not a choice worth offering.
|
|
59
|
+
|
|
60
|
+
### The pane
|
|
61
|
+
|
|
62
|
+
What `janela_pane` and `janela_frame` put in your own pages. These are
|
|
63
|
+
the names a theme spends most of its time on.
|
|
64
|
+
|
|
65
|
+
| Class | On | Rendered when |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `janela-pane` | `<table>`, `<p>`, `<figure>` or `<div>` | every pane, whatever the renderer |
|
|
68
|
+
| `janela-value` | `<p>` | a single value pane |
|
|
69
|
+
| `janela-value-label` | `<span>` | its caption |
|
|
70
|
+
| `janela-value-number` | `<strong>` | the number itself |
|
|
71
|
+
| `janela-chart` | `<canvas>` | a bar or line pane, inside its `<figure>` |
|
|
72
|
+
| `janela-chart-title` | `<figcaption>` | its caption (ADR 042) |
|
|
73
|
+
| `janela-empty` | `<p>` | a pane whose query returned nothing |
|
|
74
|
+
| `janela-error` | `<p>` | a pane that could not be read |
|
|
75
|
+
| `janela-content` | `<div>` | a stored pane holding words or a host partial rather than a query (ADR 039) |
|
|
76
|
+
| `janela-content-heading` | `<h2>` | a text pane's heading |
|
|
77
|
+
|
|
78
|
+
A table pane renders a `<caption>` and a chart pane a `<figcaption>`,
|
|
79
|
+
each its own accessible name; the chart's canvas points to its
|
|
80
|
+
figcaption with `aria-labelledby` rather than repeating the string in
|
|
81
|
+
`aria-label`, so the two cannot drift apart (ADR 042).
|
|
82
|
+
|
|
83
|
+
### Hiding a heading you already wrote
|
|
84
|
+
|
|
85
|
+
Put `janela-own-headings` on any ancestor and a table's caption, a
|
|
86
|
+
single value's label and a chart's title are hidden from sight while
|
|
87
|
+
staying in the accessibility tree.
|
|
88
|
+
|
|
89
|
+
```erb
|
|
90
|
+
<div class="janela-own-headings">
|
|
91
|
+
<h3>Revenue by status</h3>
|
|
92
|
+
<%= janela_pane Order, :revenue, by: :status %>
|
|
93
|
+
</div>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Use it when your own markup already says what the pane is, so the text
|
|
97
|
+
is not on screen twice. It hides rather than removes on purpose: a pane
|
|
98
|
+
with no visible caption still needs its accessible name, so a screen
|
|
99
|
+
reader lands on a grid of numbers with nothing to say what they
|
|
100
|
+
measure. `display: none` would do that, which is why this rule ships
|
|
101
|
+
here rather than being left for each host to write.
|
|
102
|
+
|
|
103
|
+
## What is not the contract
|
|
104
|
+
|
|
105
|
+
`janela.css` also styles Janela's own pages, the frame index and the
|
|
106
|
+
editing forms: `janela-page`, `janela-card`, `janela-button`,
|
|
107
|
+
`janela-form`, `janela-field`, `janela-list`, `janela-crumb`,
|
|
108
|
+
`janela-flash` and the rest. They are scoped under `janela-page`, which
|
|
109
|
+
only the engine's own layout sets, so they cannot touch your pages.
|
|
110
|
+
|
|
111
|
+
**Those names may change in any release.** They are the engine's own
|
|
112
|
+
chrome rather than an interface. Restyle them if you want Janela's
|
|
113
|
+
pages to match your application, and expect to revisit it after an
|
|
114
|
+
upgrade; or point `Janela.theme` at a stylesheet of your own, below.
|
|
115
|
+
|
|
116
|
+
## Writing a theme
|
|
117
|
+
|
|
118
|
+
A theme is any stylesheet, named once:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# config/initializers/janela.rb
|
|
122
|
+
Janela.theme = "midnight"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The name is resolved against your own asset paths, so `midnight.css` in
|
|
126
|
+
your application works exactly as the gem's own `vitral` does. A name
|
|
127
|
+
that resolves to nothing raises rather than quietly rendering an
|
|
128
|
+
unthemed page.
|
|
129
|
+
|
|
130
|
+
That setting links the theme into **Janela's own pages**. Your pages load
|
|
131
|
+
whatever your layout says, so if you want the same look around a pane you
|
|
132
|
+
have embedded yourself, link the stylesheet there too. The asymmetry is
|
|
133
|
+
deliberate: Janela is an isolated engine and does not write your layout
|
|
134
|
+
(ADR 011).
|
|
135
|
+
|
|
136
|
+
A theme targets the contract above and nothing else. If it cannot be
|
|
137
|
+
written that way, the contract is missing something, which is worth an
|
|
138
|
+
issue rather than a workaround.
|
|
139
|
+
|
|
140
|
+
## Vitral
|
|
141
|
+
|
|
142
|
+
The theme the gem ships. A *vitral* is a stained glass window: each pane
|
|
143
|
+
holds one of five colours, dark leading runs between them, and the light
|
|
144
|
+
comes from behind.
|
|
145
|
+
|
|
146
|
+
```erb
|
|
147
|
+
<%= stylesheet_link_tag "vitral" %>
|
|
148
|
+
<body class="vitral">
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Nothing is repainted until that class is present, so linking the
|
|
152
|
+
stylesheet can never be the thing that broke a page.
|
|
153
|
+
|
|
154
|
+
**Its own public classes**, so the page around a dashboard can be made of
|
|
155
|
+
the same window: `vitral-pane`, `vitral-panes`, `vitral-button`,
|
|
156
|
+
`vitral-button-primary`, `vitral-lattice`.
|
|
157
|
+
|
|
158
|
+
**Retheme it from its custom properties** rather than by forking it. The
|
|
159
|
+
light is `--vitral-light-cobalt`, `-teal`, `-amber`, `-rose`, `-violet`;
|
|
160
|
+
the glass is `--vitral-pane-1` through `-5`; the leading is
|
|
161
|
+
`--vitral-came`; and `--vitral-ink`, `--vitral-muted`, `--vitral-ground`,
|
|
162
|
+
`--vitral-radius`, `--vitral-shadow` and `--vitral-blur` do what they
|
|
163
|
+
say.
|
|
164
|
+
|
|
165
|
+
```css
|
|
166
|
+
:root {
|
|
167
|
+
--vitral-pane-1: rgba(120, 60, 200, 0.3);
|
|
168
|
+
--vitral-came: #1b1b1b;
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**It works without JavaScript.** Janela's own pages load none (ADR 011),
|
|
173
|
+
so the stained glass is CSS and the lattice that leans toward the pointer
|
|
174
|
+
is a separate optional controller. Reduced motion, reduced transparency
|
|
175
|
+
and increased contrast each fall back to a still, solid window, and so
|
|
176
|
+
does a browser without `backdrop-filter`.
|
|
177
|
+
|
|
178
|
+
`test/dummy` has a live page at `/vitral` showing all of it against real
|
|
179
|
+
panes.
|
data/lib/janela/definition.rb
CHANGED
|
@@ -6,8 +6,15 @@ module Janela
|
|
|
6
6
|
|
|
7
7
|
attr_reader :model, :measures, :dimensions
|
|
8
8
|
|
|
9
|
+
# The class whose janela block this is, which a subclass shares with the
|
|
10
|
+
# parent it inherited from. A subclass gets a definition of its own, so
|
|
11
|
+
# anything reporting on a declaration rather than on a model groups by
|
|
12
|
+
# this or says the same thing once per class in an STI family (ADR 035).
|
|
13
|
+
attr_accessor :declared_by
|
|
14
|
+
|
|
9
15
|
def initialize(model, &block)
|
|
10
16
|
@model = model
|
|
17
|
+
@declared_by = model
|
|
11
18
|
@measures = {}
|
|
12
19
|
@dimensions = {}
|
|
13
20
|
@block = block
|
|
@@ -19,7 +26,7 @@ module Janela
|
|
|
19
26
|
# the queries they run are its own, because ActiveRecord adds the type
|
|
20
27
|
# condition to a relation on the subclass (ADR 031).
|
|
21
28
|
def for(model)
|
|
22
|
-
self.class.new(model, &@block)
|
|
29
|
+
self.class.new(model, &@block).tap { |inherited| inherited.declared_by = declared_by }
|
|
23
30
|
end
|
|
24
31
|
|
|
25
32
|
def measure(name, **aggregate)
|
|
@@ -90,6 +97,14 @@ module Janela
|
|
|
90
97
|
dimensions.values.filter_map(&:through).map(&:to_s).uniq
|
|
91
98
|
end
|
|
92
99
|
|
|
100
|
+
# A filter the host fixed for a render (ADR 040), applied as its own
|
|
101
|
+
# condition before the reader's, so the reader's can only narrow inside
|
|
102
|
+
# it. The same bounds as any filter: declared dimensions, ADR 025's
|
|
103
|
+
# predicates and value limit.
|
|
104
|
+
def narrow(relation, conditions)
|
|
105
|
+
filter(relation, conditions.to_h)
|
|
106
|
+
end
|
|
107
|
+
|
|
93
108
|
private
|
|
94
109
|
def filter(relation, params)
|
|
95
110
|
return relation if params.empty?
|
data/lib/janela/doctor.rb
CHANGED
|
@@ -134,22 +134,43 @@ module Janela
|
|
|
134
134
|
|
|
135
135
|
# Ransack's allowlist is per class, so a dimension read through an
|
|
136
136
|
# association needs the associated model to allow the attribute too.
|
|
137
|
+
#
|
|
138
|
+
# Grouped by the allowlist that has to be written rather than by the
|
|
139
|
+
# dimension that led here, because that is what the finding is about and
|
|
140
|
+
# there is one of it. Two dimensions reading through the same
|
|
141
|
+
# association used to produce two findings whose suggested lines
|
|
142
|
+
# contradicted each other, so a host pasting both kept the second and
|
|
143
|
+
# silently lost the first; an STI family produced a copy of each per
|
|
144
|
+
# class, since a subclass shares its parent's associations (#44).
|
|
137
145
|
def through_dimensions_without_an_allowlist
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
missing_allowlist_entries.map do |klass, entry|
|
|
147
|
+
allowed = klass.ransackable_attributes.map(&:to_s)
|
|
148
|
+
Finding.new(severity: :error,
|
|
149
|
+
summary: "#{klass} does not allow filtering on #{entry[:columns].sort.to_sentence}",
|
|
150
|
+
detail: " Ransack's allowlist is per class, and these read through an association:\n" \
|
|
151
|
+
"#{entry[:sources].sort.map { |source| " #{source}" }.join("\n")}\n" \
|
|
152
|
+
" Add to #{klass}:\n" \
|
|
153
|
+
" def self.ransackable_attributes(_auth_object = nil) = " \
|
|
154
|
+
"%w[#{(allowed + entry[:columns].sort).uniq.join(' ')}]")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Keyed on the associated class rather than on the declaration, because a
|
|
159
|
+
# subclass may reflect an association its parent does not: two classes
|
|
160
|
+
# sharing a declaration and an association share a fix, and two that do
|
|
161
|
+
# not have a fix each. The source list names declaring classes, so an STI
|
|
162
|
+
# family is one line rather than one per subclass.
|
|
163
|
+
def missing_allowlist_entries
|
|
164
|
+
janela_models.each_with_object({}) do |model, missing|
|
|
165
|
+
model.janela.dimensions.values.select(&:through).each do |dimension|
|
|
140
166
|
association = model.reflect_on_association(dimension.through)
|
|
141
167
|
next unless association
|
|
168
|
+
next if association.klass.ransackable_attributes.map(&:to_s).include?(dimension.column.to_s)
|
|
142
169
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
summary: "#{association.klass} does not allow filtering on #{dimension.column}",
|
|
148
|
-
detail: " #{model}'s #{dimension.name.inspect} dimension reads it through " \
|
|
149
|
-
"#{dimension.through.inspect}, and Ransack's allowlist is per class. Add to " \
|
|
150
|
-
"#{association.klass}:\n" \
|
|
151
|
-
" def self.ransackable_attributes(_auth_object = nil) = " \
|
|
152
|
-
"%w[#{(allowed + [ dimension.column.to_s ]).uniq.join(' ')}]")
|
|
170
|
+
entry = missing[association.klass] ||= { columns: [], sources: [] }
|
|
171
|
+
entry[:columns] |= [ dimension.column.to_s ]
|
|
172
|
+
entry[:sources] |= [ "#{model.janela.declared_by}'s #{dimension.name.inspect} dimension, " \
|
|
173
|
+
"through #{dimension.through.inspect}" ]
|
|
153
174
|
end
|
|
154
175
|
end
|
|
155
176
|
end
|
|
@@ -158,11 +179,23 @@ module Janela
|
|
|
158
179
|
# but one written into the host's own Ruby is source like any other
|
|
159
180
|
# identifier this doctor already greps for (ADR 015, ADR 021, ADR 025).
|
|
160
181
|
def hardcoded_disallowed_predicates
|
|
161
|
-
|
|
162
|
-
|
|
182
|
+
janela_definitions.flat_map do |definition|
|
|
183
|
+
definition.dimensions.values.flat_map { |dimension| disallowed_uses(definition.declared_by, dimension) }
|
|
163
184
|
end
|
|
164
185
|
end
|
|
165
186
|
|
|
187
|
+
# One finding per declaration rather than one per class that inherits it.
|
|
188
|
+
# An STI family shares its parent's dimensions (ADR 031), so iterating
|
|
189
|
+
# models multiplied the same finding by the size of the family (#44).
|
|
190
|
+
def janela_definitions
|
|
191
|
+
janela_models.filter_map(&:janela).uniq(&:declared_by)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The match is a string in a file, and nothing here establishes that the
|
|
195
|
+
# file filters this model, or filters anything: a comment warning against
|
|
196
|
+
# the predicate reads the same as a call. So the finding says what was
|
|
197
|
+
# seen and admits the limit, and is a warning rather than an error
|
|
198
|
+
# (ADR 021, ADR 035, #51).
|
|
166
199
|
def disallowed_uses(model, dimension)
|
|
167
200
|
pattern = /\b#{Regexp.escape(dimension.ransack_name)}(_\w+)/
|
|
168
201
|
source_files.flat_map do |file|
|
|
@@ -174,10 +207,12 @@ module Janela
|
|
|
174
207
|
|
|
175
208
|
key = "#{dimension.ransack_name}_#{predicate}"
|
|
176
209
|
allowed = dimension.allowed_predicates.map { |p| "#{dimension.ransack_name}_#{p}" }
|
|
177
|
-
Finding.new(severity: :
|
|
210
|
+
Finding.new(severity: :warning,
|
|
178
211
|
summary: "#{model} does not allow #{key}",
|
|
179
|
-
detail: " #{file.relative_path_from(@root)}
|
|
180
|
-
"
|
|
212
|
+
detail: " #{file.relative_path_from(@root)} mentions #{key}, which Janela refuses on " \
|
|
213
|
+
"#{model} (ADR 025). Allowed here: #{allowed.join(', ')}.\n" \
|
|
214
|
+
" This reads your source for the name and cannot tell which model a match\n" \
|
|
215
|
+
" belongs to, so it may be another model's filter, or prose about one.")
|
|
181
216
|
end
|
|
182
217
|
end
|
|
183
218
|
end
|
|
@@ -186,14 +221,35 @@ module Janela
|
|
|
186
221
|
# and since ADR 032 every dashboard raises rather than answering with
|
|
187
222
|
# every row. Reported so it is found here rather than by a visitor.
|
|
188
223
|
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
224
|
+
# ADR 032 called this check exact, on the reasoning that the method is
|
|
225
|
+
# defined or it is not. That was wrong, and ADR 035 supersedes it: Pundit
|
|
226
|
+
# defines policy_scope the moment it is included, whether or not the
|
|
227
|
+
# model has a policy, so for the commonest authorisation library defined
|
|
228
|
+
# and works are different questions. The check makes the call Janela
|
|
229
|
+
# makes rather than reading the method table (#51).
|
|
192
230
|
def unscoped_reads
|
|
193
|
-
parent =
|
|
231
|
+
parent = parent_controller
|
|
194
232
|
return unless parent
|
|
195
|
-
return
|
|
233
|
+
return undefined_policy_scope(parent) unless answers_policy_scope?(parent)
|
|
234
|
+
return unless Janela::Frame.table_exists? && Janela::Snapshot.table_exists?
|
|
235
|
+
|
|
236
|
+
[ Janela::Frame, Janela::Snapshot ].filter_map do |model|
|
|
237
|
+
outcome, answer = ask_for_scope(model)
|
|
238
|
+
next unless outcome == :raised
|
|
239
|
+
|
|
240
|
+
Finding.new(severity: :error,
|
|
241
|
+
summary: "#{parent}'s policy_scope raises for #{model}, so every pane will too",
|
|
242
|
+
detail: " Janela makes this exact call on every dashboard request, and got:\n" \
|
|
243
|
+
" #{answer.class}: #{answer.message.to_s.lines.first.to_s.strip.truncate(140)}\n" \
|
|
244
|
+
" Janela's own records go through your policy like any other model's, so\n" \
|
|
245
|
+
" they need whatever yours need: with Pundit that is a policy class for\n" \
|
|
246
|
+
" #{model}. docs/multi-tenancy.md has the wiring.")
|
|
247
|
+
end
|
|
248
|
+
rescue StandardError
|
|
249
|
+
nil # no database to ask yet
|
|
250
|
+
end
|
|
196
251
|
|
|
252
|
+
def undefined_policy_scope(parent)
|
|
197
253
|
Finding.new(severity: :error,
|
|
198
254
|
summary: "#{parent} defines no policy_scope, so every pane will raise",
|
|
199
255
|
detail: " Janela asks your controller what may be read and refuses to guess.\n" \
|
|
@@ -204,15 +260,19 @@ module Janela
|
|
|
204
260
|
" narrower; docs/multi-tenancy.md has the wiring for the usual libraries.")
|
|
205
261
|
end
|
|
206
262
|
|
|
263
|
+
def answers_policy_scope?(parent)
|
|
264
|
+
parent.private_method_defined?(:policy_scope) || parent.method_defined?(:policy_scope)
|
|
265
|
+
end
|
|
266
|
+
|
|
207
267
|
# A host whose policy filters frames by owner, but which never tells
|
|
208
268
|
# Janela what owns a new one, creates frames its own scope then hides.
|
|
209
269
|
# The failure is silent, and a typo in the method name looks the same as
|
|
210
270
|
# not defining it, which is the cost of asking by duck typing (ADR 019).
|
|
211
271
|
def frames_nobody_will_own
|
|
212
|
-
parent =
|
|
213
|
-
return unless parent
|
|
272
|
+
parent = parent_controller
|
|
273
|
+
return unless parent && answers_policy_scope?(parent)
|
|
214
274
|
return if parent.private_method_defined?(:janela_frame_owner) || parent.method_defined?(:janela_frame_owner)
|
|
215
|
-
return unless Janela::Frame.table_exists? &&
|
|
275
|
+
return unless Janela::Frame.table_exists? && owner_filtering(Janela::Frame) == :filtered
|
|
216
276
|
|
|
217
277
|
Finding.new(severity: :error,
|
|
218
278
|
summary: "#{parent} scopes frames by owner but defines no janela_frame_owner",
|
|
@@ -236,9 +296,9 @@ module Janela
|
|
|
236
296
|
# times in this repository's tests and once on the live demo within an
|
|
237
297
|
# afternoon of the column landing (#49).
|
|
238
298
|
def snapshots_nobody_will_see
|
|
239
|
-
parent =
|
|
299
|
+
parent = parent_controller
|
|
240
300
|
return unless parent && Janela::Snapshot.table_exists?
|
|
241
|
-
return unless
|
|
301
|
+
return unless owner_filtering(Janela::Snapshot) == :filtered
|
|
242
302
|
|
|
243
303
|
unowned = Janela::Snapshot.where(owner_id: nil).count
|
|
244
304
|
return if unowned.zero?
|
|
@@ -256,17 +316,46 @@ module Janela
|
|
|
256
316
|
# Asking the policy rather than reading its source: a scope that narrows
|
|
257
317
|
# an owned record is one that will hide an unowned one. Shared, because
|
|
258
318
|
# a frame and a snapshot are the same question asked of two tables.
|
|
259
|
-
|
|
260
|
-
|
|
319
|
+
#
|
|
320
|
+
# Three-valued on purpose. Collapsing a raise into false is how both
|
|
321
|
+
# owner checks came to be silent for every host whose policy reaches for
|
|
322
|
+
# the signed in user: a raise is a different fact from a scope that does
|
|
323
|
+
# not filter, and unscoped_reads is the check that reports it (ADR 035).
|
|
324
|
+
def owner_filtering(model)
|
|
325
|
+
outcome, answer = ask_for_scope(model)
|
|
326
|
+
return :raised if outcome == :raised
|
|
327
|
+
|
|
328
|
+
answer.to_sql.include?("owner") ? :filtered : :unfiltered
|
|
261
329
|
rescue StandardError
|
|
262
|
-
|
|
330
|
+
:raised
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# What Janela's controllers actually inherit. Janela.parent_controller is
|
|
334
|
+
# what a host asked for, and the two differ when it was named too late,
|
|
335
|
+
# which is how a check came to print "Janela's controllers inherit X"
|
|
336
|
+
# about a class that was not in the chain (ADR 035, #38).
|
|
337
|
+
def parent_controller
|
|
338
|
+
Janela::ApplicationController.superclass
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Asks the host's policy the way a request does. On a controller with no
|
|
342
|
+
# request, session, params and current_user are all unreachable, so a
|
|
343
|
+
# policy that touches any of them raises for a reason that is not the
|
|
344
|
+
# host's fault. Given a request they are empty instead, which is an
|
|
345
|
+
# unauthenticated visitor: the right thing for a check to ask about.
|
|
346
|
+
def ask_for_scope(model)
|
|
347
|
+
controller = parent_controller.allocate
|
|
348
|
+
controller.set_request!(ActionDispatch::TestRequest.create) if controller.respond_to?(:set_request!)
|
|
349
|
+
[ :ok, controller.send(:policy_scope, model) ]
|
|
350
|
+
rescue StandardError => e
|
|
351
|
+
[ :raised, e ]
|
|
263
352
|
end
|
|
264
353
|
|
|
265
354
|
# Whether an endpoint is public depends on what the host's controller
|
|
266
355
|
# does, which cannot be determined by reading, so this observes and says
|
|
267
356
|
# so rather than declaring anything safe.
|
|
268
357
|
def unauthenticated_endpoints
|
|
269
|
-
parent =
|
|
358
|
+
parent = parent_controller
|
|
270
359
|
return unless parent
|
|
271
360
|
|
|
272
361
|
filters = parent._process_action_callbacks.map(&:filter).map(&:to_s)
|
|
@@ -281,7 +370,7 @@ module Janela
|
|
|
281
370
|
|
|
282
371
|
def janela_models
|
|
283
372
|
Rails.application.eager_load!
|
|
284
|
-
ActiveRecord::Base.descendants.select { |model|
|
|
373
|
+
ActiveRecord::Base.descendants.select { |model| Janela.our_definition(model) }
|
|
285
374
|
rescue StandardError
|
|
286
375
|
[]
|
|
287
376
|
end
|
data/lib/janela/model.rb
CHANGED
|
@@ -29,9 +29,14 @@ module Janela
|
|
|
29
29
|
# does not exist yet, so it registers as it is created (ADR 031). An
|
|
30
30
|
# anonymous class has no route key to be addressed by; naming it is the
|
|
31
31
|
# host's move and declaring on it is the host's other one.
|
|
32
|
+
#
|
|
33
|
+
# A Definition rather than anything truthy: this is extended onto every
|
|
34
|
+
# model in the application, so `janela` may be a method the host wrote for
|
|
35
|
+
# its own reasons, and the host's own wins. Janela believes only what
|
|
36
|
+
# Janela built (#54).
|
|
32
37
|
def inherited(subclass)
|
|
33
38
|
super
|
|
34
|
-
Janela.register_subclass(subclass) if subclass.name && janela
|
|
39
|
+
Janela.register_subclass(subclass) if subclass.name && janela.is_a?(Definition)
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
private
|
data/lib/janela/version.rb
CHANGED
data/lib/janela.rb
CHANGED
|
@@ -27,7 +27,41 @@ module Janela
|
|
|
27
27
|
|
|
28
28
|
# Janela's controllers inherit from the host's, so the host's authentication
|
|
29
29
|
# and authorisation apply to dashboards with no configuration.
|
|
30
|
-
|
|
30
|
+
#
|
|
31
|
+
# Resolved once, when Janela::ApplicationController is first loaded. A host
|
|
32
|
+
# naming one after that point used to be ignored in silence: dashboards kept
|
|
33
|
+
# inheriting whatever was named first, so the host's authentication and its
|
|
34
|
+
# policy_scope were not the ones it had written, and nothing said so. Naming
|
|
35
|
+
# a different one too late raises instead (ADR 035, #38).
|
|
36
|
+
@parent_controller = "ApplicationController"
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
attr_reader :parent_controller
|
|
40
|
+
|
|
41
|
+
def parent_controller=(name)
|
|
42
|
+
inherited = inherited_controller
|
|
43
|
+
if inherited && inherited != name.to_s
|
|
44
|
+
raise Error, "Janela::ApplicationController already inherits #{inherited}, so naming " \
|
|
45
|
+
"#{name} now would do nothing: the superclass is resolved once, the first " \
|
|
46
|
+
"time the class is loaded. Set it earlier, in config/initializers/janela.rb, " \
|
|
47
|
+
"which runs before anything can load a Janela controller. config.to_prepare " \
|
|
48
|
+
"and config.after_initialize are both too late, and so is anything that runs " \
|
|
49
|
+
"once the application is serving."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@parent_controller = name
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# What Janela::ApplicationController resolved to, or nil if it has not been
|
|
56
|
+
# loaded yet. Asked without forcing the autoload the question is about:
|
|
57
|
+
# const_defined? is true for a registered autoload, and autoload? stops
|
|
58
|
+
# being truthy only once the file has actually been loaded.
|
|
59
|
+
def inherited_controller
|
|
60
|
+
return nil if autoload?(:ApplicationController) || !const_defined?(:ApplicationController, false)
|
|
61
|
+
|
|
62
|
+
const_get(:ApplicationController, false).superclass.name
|
|
63
|
+
end
|
|
64
|
+
end
|
|
31
65
|
|
|
32
66
|
# The stylesheet Janela's own pages load on top of janela.css. Nil means the
|
|
33
67
|
# structural one only, which is what a host that has its own look wants. The
|
|
@@ -95,7 +129,7 @@ module Janela
|
|
|
95
129
|
# here until a restart, and a form offering it would fail to draw at all.
|
|
96
130
|
def self.definitions
|
|
97
131
|
Rails.application.eager_load!
|
|
98
|
-
registry.sort.filter_map { |_route_key, class_name| class_name.safe_constantize
|
|
132
|
+
registry.sort.filter_map { |_route_key, class_name| our_definition(class_name.safe_constantize) }
|
|
99
133
|
end
|
|
100
134
|
|
|
101
135
|
def self.definition!(route_key)
|
|
@@ -104,7 +138,17 @@ module Janela
|
|
|
104
138
|
Rails.application.eager_load! unless registry.key?(route_key)
|
|
105
139
|
class_name = registry.fetch(route_key) { raise NotFound, "#{route_key.inspect} is not a janela model" }
|
|
106
140
|
|
|
107
|
-
class_name.constantize
|
|
141
|
+
our_definition(class_name.constantize) ||
|
|
142
|
+
raise(NotFound, "#{route_key.inspect} is not a janela model")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Janela::Model is extended onto every model in the application, so `janela`
|
|
146
|
+
# is a question any of them can answer, and a host that answers it for its
|
|
147
|
+
# own reasons wins: its method is on its own singleton. Janela believes only
|
|
148
|
+
# what Janela built, rather than anything truthy that comes back (#54).
|
|
149
|
+
def self.our_definition(model)
|
|
150
|
+
definition = model.janela if model.respond_to?(:janela)
|
|
151
|
+
definition if definition.is_a?(Definition)
|
|
108
152
|
end
|
|
109
153
|
|
|
110
154
|
# What Janela can draw, so a gallery asks rather than reaching into
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: janela
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jay Killeen
|
|
@@ -114,6 +114,7 @@ files:
|
|
|
114
114
|
- app/models/janela/query.rb
|
|
115
115
|
- app/models/janela/snapshot.rb
|
|
116
116
|
- app/views/janela/frames/_card.html.erb
|
|
117
|
+
- app/views/janela/frames/_content.html.erb
|
|
117
118
|
- app/views/janela/frames/_form.html.erb
|
|
118
119
|
- app/views/janela/frames/_frame.html.erb
|
|
119
120
|
- app/views/janela/frames/_pane.html.erb
|
|
@@ -121,6 +122,7 @@ files:
|
|
|
121
122
|
- app/views/janela/frames/index.html.erb
|
|
122
123
|
- app/views/janela/frames/new.html.erb
|
|
123
124
|
- app/views/janela/frames/show.html.erb
|
|
125
|
+
- app/views/janela/panes/_content_form.html.erb
|
|
124
126
|
- app/views/janela/panes/_form.html.erb
|
|
125
127
|
- app/views/janela/panes/_row.html.erb
|
|
126
128
|
- app/views/janela/panes/edit.html.erb
|
|
@@ -137,6 +139,9 @@ files:
|
|
|
137
139
|
- db/migrate/20260916000001_create_janela_frames.rb
|
|
138
140
|
- db/migrate/20260916000002_create_janela_panes.rb
|
|
139
141
|
- db/migrate/20260921000001_add_owner_to_janela_snapshots.rb
|
|
142
|
+
- db/migrate/20260924000001_add_content_to_janela_panes.rb
|
|
143
|
+
- db/migrate/20260924000002_add_key_to_janela_frames.rb
|
|
144
|
+
- docs/composing.md
|
|
140
145
|
- docs/decisions/001-built-to-be-forked.md
|
|
141
146
|
- docs/decisions/002-measures-and-dimensions-over-ransack.md
|
|
142
147
|
- docs/decisions/003-cross-filtering-with-turbo-frames.md
|
|
@@ -171,9 +176,19 @@ files:
|
|
|
171
176
|
- docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md
|
|
172
177
|
- docs/decisions/033-a-snapshot-is-told-who-owns-it.md
|
|
173
178
|
- docs/decisions/034-janela-will-not-freeze-a-scope-the-host-has-not-named.md
|
|
179
|
+
- docs/decisions/035-a-check-does-what-janela-does-or-says-what-it-saw.md
|
|
180
|
+
- docs/decisions/036-janela-publishes-what-a-theme-may-target.md
|
|
181
|
+
- docs/decisions/037-what-1-0-means.md
|
|
182
|
+
- docs/decisions/038-a-ratio-is-a-measure-of-its-own.md
|
|
183
|
+
- docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md
|
|
184
|
+
- docs/decisions/040-a-host-can-fix-a-frames-filter.md
|
|
185
|
+
- docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md
|
|
186
|
+
- docs/decisions/042-a-charts-title-is-a-figcaption.md
|
|
174
187
|
- docs/decisions/INDEX.md
|
|
175
188
|
- docs/multi-tenancy.md
|
|
176
189
|
- docs/naming.md
|
|
190
|
+
- docs/roadmap.md
|
|
191
|
+
- docs/theming.md
|
|
177
192
|
- lib/janela.rb
|
|
178
193
|
- lib/janela/definition.rb
|
|
179
194
|
- lib/janela/dimension.rb
|
|
@@ -194,22 +209,22 @@ metadata:
|
|
|
194
209
|
bug_tracker_uri: https://github.com/retail-tasker/janela/issues
|
|
195
210
|
rubygems_mfa_required: 'true'
|
|
196
211
|
post_install_message: |
|
|
197
|
-
Janela 0.
|
|
198
|
-
|
|
212
|
+
Janela 0.9.0: a migration if you use stored frames, and a markup
|
|
213
|
+
check if you wrote CSS or JavaScript against a chart pane.
|
|
199
214
|
|
|
200
|
-
1. If
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
215
|
+
1. If you use stored frames, take two migrations together:
|
|
216
|
+
bin/rails janela:install:migrations && bin/rails db:migrate
|
|
217
|
+
A pane can now hold words or a host partial instead of a query
|
|
218
|
+
(ADR 039), and Janela::Frame.for(owner, key) finds a frame kept
|
|
219
|
+
for one of your pages without a column of your own (ADR 041).
|
|
220
|
+
Existing frames and panes are unaffected either way.
|
|
204
221
|
|
|
205
|
-
2.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
or
|
|
210
|
-
|
|
211
|
-
If you use snapshots, there is also a migration: janela_snapshots
|
|
212
|
-
gains a nullable owner (ADR 033).
|
|
222
|
+
2. A chart pane's title is now a visible <figcaption> inside a
|
|
223
|
+
<figure> wrapping the canvas, not only the canvas's aria-label
|
|
224
|
+
(ADR 042). janela-pane moved from the <canvas> to the <figure>;
|
|
225
|
+
janela-chart stayed on the canvas. If you selected
|
|
226
|
+
.janela-pane.janela-chart as one element, or read a chart's
|
|
227
|
+
title from aria-label, both need updating.
|
|
213
228
|
|
|
214
229
|
Steps: UPGRADING.md in this gem, or
|
|
215
230
|
https://github.com/retail-tasker/janela/blob/main/UPGRADING.md
|