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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +36 -5
  4. data/UPGRADING.md +111 -0
  5. data/app/assets/javascripts/janela/frame_controller.js +15 -0
  6. data/app/assets/stylesheets/janela.css +29 -0
  7. data/app/controllers/janela/application_controller.rb +7 -0
  8. data/app/controllers/janela/panes_controller.rb +22 -7
  9. data/app/controllers/janela/queries_controller.rb +2 -1
  10. data/app/helpers/janela/frames_helper.rb +26 -7
  11. data/app/models/janela/frame.rb +20 -0
  12. data/app/models/janela/pane.rb +67 -4
  13. data/app/models/janela/query.rb +7 -2
  14. data/app/views/janela/frames/_content.html.erb +26 -0
  15. data/app/views/janela/frames/_frame.html.erb +9 -1
  16. data/app/views/janela/frames/_pane.html.erb +2 -2
  17. data/app/views/janela/panes/_content_form.html.erb +33 -0
  18. data/app/views/janela/panes/_row.html.erb +1 -1
  19. data/app/views/janela/panes/edit.html.erb +5 -1
  20. data/app/views/janela/panes/new.html.erb +6 -1
  21. data/app/views/janela/queries/_query.html.erb +15 -11
  22. data/config/locales/en.yml +5 -0
  23. data/db/migrate/20260924000001_add_content_to_janela_panes.rb +16 -0
  24. data/db/migrate/20260924000002_add_key_to_janela_frames.rb +10 -0
  25. data/docs/composing.md +269 -0
  26. data/docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md +1 -0
  27. data/docs/decisions/035-a-check-does-what-janela-does-or-says-what-it-saw.md +262 -0
  28. data/docs/decisions/036-janela-publishes-what-a-theme-may-target.md +171 -0
  29. data/docs/decisions/037-what-1-0-means.md +172 -0
  30. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +194 -0
  31. data/docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md +168 -0
  32. data/docs/decisions/040-a-host-can-fix-a-frames-filter.md +145 -0
  33. data/docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md +112 -0
  34. data/docs/decisions/042-a-charts-title-is-a-figcaption.md +174 -0
  35. data/docs/decisions/INDEX.md +26 -15
  36. data/docs/multi-tenancy.md +22 -0
  37. data/docs/naming.md +7 -0
  38. data/docs/roadmap.md +218 -0
  39. data/docs/theming.md +179 -0
  40. data/lib/janela/definition.rb +16 -1
  41. data/lib/janela/doctor.rb +121 -32
  42. data/lib/janela/model.rb +6 -1
  43. data/lib/janela/version.rb +1 -1
  44. data/lib/janela.rb +47 -3
  45. 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.
@@ -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
- janela_models.flat_map do |model|
139
- model.janela.dimensions.values.select(&:through).filter_map do |dimension|
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
- allowed = association.klass.ransackable_attributes.map(&:to_s)
144
- next if allowed.include?(dimension.column.to_s)
145
-
146
- Finding.new(severity: :error,
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
- janela_models.flat_map do |model|
162
- model.janela.dimensions.values.flat_map { |dimension| disallowed_uses(model, dimension) }
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: :error,
210
+ Finding.new(severity: :warning,
178
211
  summary: "#{model} does not allow #{key}",
179
- detail: " #{file.relative_path_from(@root)} filters #{model} on #{key}, which Janela now " \
180
- "refuses (ADR 025). Allowed here: #{allowed.join(', ')}.")
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
- # Where unauthenticated_endpoints has to hedge, because what counts as
190
- # authentication cannot be determined by reading, this one is exact: the
191
- # method is defined or it is not, and that is the whole contract.
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 = Janela.parent_controller.safe_constantize
231
+ parent = parent_controller
194
232
  return unless parent
195
- return if parent.private_method_defined?(:policy_scope) || parent.method_defined?(:policy_scope)
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 = Janela.parent_controller.safe_constantize
213
- return unless parent&.private_method_defined?(:policy_scope) || parent&.method_defined?(:policy_scope)
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? && scope_filters_by_owner?(parent, Janela::Frame)
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 = Janela.parent_controller.safe_constantize
299
+ parent = parent_controller
240
300
  return unless parent && Janela::Snapshot.table_exists?
241
- return unless scope_filters_by_owner?(parent, Janela::Snapshot)
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
- def scope_filters_by_owner?(parent, model)
260
- parent.allocate.send(:policy_scope, model).to_sql.include?("owner")
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
- false
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 = Janela.parent_controller.safe_constantize
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| model.respond_to?(:janela) && model.janela }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
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
- mattr_accessor :parent_controller, default: "ApplicationController"
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&.janela }
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.janela
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.7.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.7.0 stops guessing what may be read. Two things now refuse
198
- rather than defaulting to every row, and both are quick to answer.
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 your ApplicationController defines no policy_scope, every pane
201
- raises Janela::Unscoped instead of totalling every row. Pundit
202
- hosts and anyone following docs/multi-tenancy.md: nothing to do.
203
- Everyone else writes one line (ADR 032).
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. If you schedule Janela::SnapshotJob, it now needs to be told what
206
- rows to freeze: pass scope: :model_default, or subclass it and
207
- override scope_for. A job already on your queue was serialised
208
- without that argument and will raise when it performs, so drain it
209
- or re-enqueue (ADR 034).
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