janela 0.6.0 → 0.8.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 +42 -0
- data/README.md +29 -10
- data/UPGRADING.md +167 -0
- data/app/assets/stylesheets/janela.css +21 -0
- data/app/controllers/janela/application_controller.rb +3 -2
- data/app/helpers/janela/frames_helper.rb +4 -2
- data/app/jobs/janela/snapshot_job.rb +47 -6
- data/app/models/janela/snapshot.rb +11 -2
- data/db/migrate/20260921000001_add_owner_to_janela_snapshots.rb +9 -0
- data/docs/decisions/009-snapshots.md +1 -1
- data/docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md +157 -0
- data/docs/decisions/033-a-snapshot-is-told-who-owns-it.md +151 -0
- data/docs/decisions/034-janela-will-not-freeze-a-scope-the-host-has-not-named.md +238 -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/INDEX.md +18 -10
- data/docs/multi-tenancy.md +87 -18
- data/docs/roadmap.md +97 -0
- data/docs/theming.md +177 -0
- data/lib/janela/definition.rb +8 -1
- data/lib/janela/doctor.rb +169 -29
- data/lib/janela/model.rb +6 -1
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +74 -3
- metadata +27 -8
data/lib/janela/doctor.rb
CHANGED
|
@@ -10,8 +10,9 @@ module Janela
|
|
|
10
10
|
# Run in this order, and each one names the finding it produces: a host
|
|
11
11
|
# silences a check by that name (ADR 021).
|
|
12
12
|
CHECKS = %i[stale_identifiers unmounted_engine unmigrated_tables unregistered_controllers
|
|
13
|
-
through_dimensions_without_an_allowlist frames_nobody_will_own
|
|
14
|
-
unauthenticated_endpoints
|
|
13
|
+
through_dimensions_without_an_allowlist unscoped_reads frames_nobody_will_own
|
|
14
|
+
snapshots_nobody_will_see unauthenticated_endpoints
|
|
15
|
+
hardcoded_disallowed_predicates].freeze
|
|
15
16
|
|
|
16
17
|
# Identifiers a previous version of Janela used, and what replaced them.
|
|
17
18
|
RENAMED = {
|
|
@@ -133,22 +134,43 @@ module Janela
|
|
|
133
134
|
|
|
134
135
|
# Ransack's allowlist is per class, so a dimension read through an
|
|
135
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).
|
|
136
145
|
def through_dimensions_without_an_allowlist
|
|
137
|
-
|
|
138
|
-
|
|
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|
|
|
139
166
|
association = model.reflect_on_association(dimension.through)
|
|
140
167
|
next unless association
|
|
168
|
+
next if association.klass.ransackable_attributes.map(&:to_s).include?(dimension.column.to_s)
|
|
141
169
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(' ')}]")
|
|
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}" ]
|
|
152
174
|
end
|
|
153
175
|
end
|
|
154
176
|
end
|
|
@@ -157,11 +179,23 @@ module Janela
|
|
|
157
179
|
# but one written into the host's own Ruby is source like any other
|
|
158
180
|
# identifier this doctor already greps for (ADR 015, ADR 021, ADR 025).
|
|
159
181
|
def hardcoded_disallowed_predicates
|
|
160
|
-
|
|
161
|
-
|
|
182
|
+
janela_definitions.flat_map do |definition|
|
|
183
|
+
definition.dimensions.values.flat_map { |dimension| disallowed_uses(definition.declared_by, dimension) }
|
|
162
184
|
end
|
|
163
185
|
end
|
|
164
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).
|
|
165
199
|
def disallowed_uses(model, dimension)
|
|
166
200
|
pattern = /\b#{Regexp.escape(dimension.ransack_name)}(_\w+)/
|
|
167
201
|
source_files.flat_map do |file|
|
|
@@ -173,23 +207,72 @@ module Janela
|
|
|
173
207
|
|
|
174
208
|
key = "#{dimension.ransack_name}_#{predicate}"
|
|
175
209
|
allowed = dimension.allowed_predicates.map { |p| "#{dimension.ransack_name}_#{p}" }
|
|
176
|
-
Finding.new(severity: :
|
|
210
|
+
Finding.new(severity: :warning,
|
|
177
211
|
summary: "#{model} does not allow #{key}",
|
|
178
|
-
detail: " #{file.relative_path_from(@root)}
|
|
179
|
-
"
|
|
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.")
|
|
180
216
|
end
|
|
181
217
|
end
|
|
182
218
|
end
|
|
183
219
|
|
|
220
|
+
# A host that has defined no policy_scope has not said what may be read,
|
|
221
|
+
# and since ADR 032 every dashboard raises rather than answering with
|
|
222
|
+
# every row. Reported so it is found here rather than by a visitor.
|
|
223
|
+
#
|
|
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).
|
|
230
|
+
def unscoped_reads
|
|
231
|
+
parent = parent_controller
|
|
232
|
+
return unless parent
|
|
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
|
|
251
|
+
|
|
252
|
+
def undefined_policy_scope(parent)
|
|
253
|
+
Finding.new(severity: :error,
|
|
254
|
+
summary: "#{parent} defines no policy_scope, so every pane will raise",
|
|
255
|
+
detail: " Janela asks your controller what may be read and refuses to guess.\n" \
|
|
256
|
+
" Define it on #{parent}:\n" \
|
|
257
|
+
" private def policy_scope(model) = model.all\n" \
|
|
258
|
+
" That line says every visitor may read every row of every model on a\n" \
|
|
259
|
+
" dashboard. If that is not true of this application, return something\n" \
|
|
260
|
+
" narrower; docs/multi-tenancy.md has the wiring for the usual libraries.")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def answers_policy_scope?(parent)
|
|
264
|
+
parent.private_method_defined?(:policy_scope) || parent.method_defined?(:policy_scope)
|
|
265
|
+
end
|
|
266
|
+
|
|
184
267
|
# A host whose policy filters frames by owner, but which never tells
|
|
185
268
|
# Janela what owns a new one, creates frames its own scope then hides.
|
|
186
269
|
# The failure is silent, and a typo in the method name looks the same as
|
|
187
270
|
# not defining it, which is the cost of asking by duck typing (ADR 019).
|
|
188
271
|
def frames_nobody_will_own
|
|
189
|
-
parent =
|
|
190
|
-
return unless parent
|
|
272
|
+
parent = parent_controller
|
|
273
|
+
return unless parent && answers_policy_scope?(parent)
|
|
191
274
|
return if parent.private_method_defined?(:janela_frame_owner) || parent.method_defined?(:janela_frame_owner)
|
|
192
|
-
return unless Janela::Frame.table_exists? &&
|
|
275
|
+
return unless Janela::Frame.table_exists? && owner_filtering(Janela::Frame) == :filtered
|
|
193
276
|
|
|
194
277
|
Finding.new(severity: :error,
|
|
195
278
|
summary: "#{parent} scopes frames by owner but defines no janela_frame_owner",
|
|
@@ -202,20 +285,77 @@ module Janela
|
|
|
202
285
|
nil # no database or no policy to ask; nothing can be concluded
|
|
203
286
|
end
|
|
204
287
|
|
|
288
|
+
# A snapshot with no owner is stored and unreachable to a policy that
|
|
289
|
+
# filters on one: the row is there, a link to it is a 404, and nothing
|
|
290
|
+
# says why. Most often these were taken before the column existed, which
|
|
291
|
+
# is what an upgrade produces.
|
|
292
|
+
#
|
|
293
|
+
# ADR 033 judged this a thinner case than the frame's, because a caller
|
|
294
|
+
# assigns a snapshot's owner in its own Ruby rather than the engine doing
|
|
295
|
+
# it silently, and said it was worth revisiting if it bit. It bit four
|
|
296
|
+
# times in this repository's tests and once on the live demo within an
|
|
297
|
+
# afternoon of the column landing (#49).
|
|
298
|
+
def snapshots_nobody_will_see
|
|
299
|
+
parent = parent_controller
|
|
300
|
+
return unless parent && Janela::Snapshot.table_exists?
|
|
301
|
+
return unless owner_filtering(Janela::Snapshot) == :filtered
|
|
302
|
+
|
|
303
|
+
unowned = Janela::Snapshot.where(owner_id: nil).count
|
|
304
|
+
return if unowned.zero?
|
|
305
|
+
|
|
306
|
+
Finding.new(severity: :warning,
|
|
307
|
+
summary: "#{unowned} #{'snapshot'.pluralize(unowned)} with no owner, which your policy filters on",
|
|
308
|
+
detail: " Your policy narrows snapshots by owner, so one with none is stored and\n" \
|
|
309
|
+
" unreachable: a link to it answers 404 and nothing says why. Usually these\n" \
|
|
310
|
+
" were taken before the owner column existed. Assign an owner to them, or\n" \
|
|
311
|
+
" delete them, and pass owner: to Janela::Snapshot.take from now on.")
|
|
312
|
+
rescue StandardError
|
|
313
|
+
nil # no database or no policy to ask; nothing can be concluded
|
|
314
|
+
end
|
|
315
|
+
|
|
205
316
|
# Asking the policy rather than reading its source: a scope that narrows
|
|
206
|
-
#
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
317
|
+
# an owned record is one that will hide an unowned one. Shared, because
|
|
318
|
+
# a frame and a snapshot are the same question asked of two tables.
|
|
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
|
|
210
329
|
rescue StandardError
|
|
211
|
-
|
|
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 ]
|
|
212
352
|
end
|
|
213
353
|
|
|
214
354
|
# Whether an endpoint is public depends on what the host's controller
|
|
215
355
|
# does, which cannot be determined by reading, so this observes and says
|
|
216
356
|
# so rather than declaring anything safe.
|
|
217
357
|
def unauthenticated_endpoints
|
|
218
|
-
parent =
|
|
358
|
+
parent = parent_controller
|
|
219
359
|
return unless parent
|
|
220
360
|
|
|
221
361
|
filters = parent._process_action_callbacks.map(&:filter).map(&:to_s)
|
|
@@ -230,7 +370,7 @@ module Janela
|
|
|
230
370
|
|
|
231
371
|
def janela_models
|
|
232
372
|
Rails.application.eager_load!
|
|
233
|
-
ActiveRecord::Base.descendants.select { |model|
|
|
373
|
+
ActiveRecord::Base.descendants.select { |model| Janela.our_definition(model) }
|
|
234
374
|
rescue StandardError
|
|
235
375
|
[]
|
|
236
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
|
@@ -20,10 +20,48 @@ module Janela
|
|
|
20
20
|
# Something the request asked for is not allowed here: a renderer, a
|
|
21
21
|
# granularity, a limit or a filter. Rendered as 400.
|
|
22
22
|
class BadRequest < Error; end
|
|
23
|
+
# The host has not said what may be read. Not rendered as anything: it is a
|
|
24
|
+
# setup mistake rather than a data condition, and dressing it as a 404 would
|
|
25
|
+
# hide the one line that fixes it (ADR 032).
|
|
26
|
+
class Unscoped < Error; end
|
|
23
27
|
|
|
24
28
|
# Janela's controllers inherit from the host's, so the host's authentication
|
|
25
29
|
# and authorisation apply to dashboards with no configuration.
|
|
26
|
-
|
|
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
|
|
27
65
|
|
|
28
66
|
# The stylesheet Janela's own pages load on top of janela.css. Nil means the
|
|
29
67
|
# structural one only, which is what a host that has its own look wants. The
|
|
@@ -37,6 +75,29 @@ module Janela
|
|
|
37
75
|
# is for (ADR 021).
|
|
38
76
|
mattr_accessor :silenced_checks, default: []
|
|
39
77
|
|
|
78
|
+
# What the host permits to be read, asked of its controller and never
|
|
79
|
+
# assumed. Janela refuses rather than reading everything, because a
|
|
80
|
+
# dashboard that quietly totals rows its reader may not see is the worst
|
|
81
|
+
# thing this library can do (ADR 032).
|
|
82
|
+
#
|
|
83
|
+
# Asked of the controller and never of self, because a helper runs on the
|
|
84
|
+
# view and a view cannot see a private controller method: when there were
|
|
85
|
+
# two copies of this question they disagreed about whether a host had
|
|
86
|
+
# answered it (#46). One copy, for that reason.
|
|
87
|
+
def self.scope(controller, model)
|
|
88
|
+
unless controller.respond_to?(:policy_scope, true)
|
|
89
|
+
raise Unscoped, "#{controller.class} defines no policy_scope, so Janela has not been " \
|
|
90
|
+
"told what may be read of #{model.name}. Define it on " \
|
|
91
|
+
"#{Janela.parent_controller}, returning the rows this visitor may see:\n\n" \
|
|
92
|
+
" private def policy_scope(model) = model.all\n\n" \
|
|
93
|
+
"That line says every visitor may read every row of every model on a " \
|
|
94
|
+
"dashboard. If that is not true here, return something narrower. " \
|
|
95
|
+
"UPGRADING.md has the steps and docs/multi-tenancy.md has the wiring."
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
controller.send(:policy_scope, model)
|
|
99
|
+
end
|
|
100
|
+
|
|
40
101
|
# A model that declares a janela block is addressable over HTTP, and so is a
|
|
41
102
|
# subclass of one, keyed by the route key that appears in pane URLs (orders,
|
|
42
103
|
# sales_orders). Names are stored rather than classes so a reloaded model
|
|
@@ -68,7 +129,7 @@ module Janela
|
|
|
68
129
|
# here until a restart, and a form offering it would fail to draw at all.
|
|
69
130
|
def self.definitions
|
|
70
131
|
Rails.application.eager_load!
|
|
71
|
-
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) }
|
|
72
133
|
end
|
|
73
134
|
|
|
74
135
|
def self.definition!(route_key)
|
|
@@ -77,7 +138,17 @@ module Janela
|
|
|
77
138
|
Rails.application.eager_load! unless registry.key?(route_key)
|
|
78
139
|
class_name = registry.fetch(route_key) { raise NotFound, "#{route_key.inspect} is not a janela model" }
|
|
79
140
|
|
|
80
|
-
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)
|
|
81
152
|
end
|
|
82
153
|
|
|
83
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.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jay Killeen
|
|
@@ -136,6 +136,7 @@ files:
|
|
|
136
136
|
- db/migrate/20260915000001_create_janela_snapshots.rb
|
|
137
137
|
- db/migrate/20260916000001_create_janela_frames.rb
|
|
138
138
|
- db/migrate/20260916000002_create_janela_panes.rb
|
|
139
|
+
- db/migrate/20260921000001_add_owner_to_janela_snapshots.rb
|
|
139
140
|
- docs/decisions/001-built-to-be-forked.md
|
|
140
141
|
- docs/decisions/002-measures-and-dimensions-over-ransack.md
|
|
141
142
|
- docs/decisions/003-cross-filtering-with-turbo-frames.md
|
|
@@ -167,9 +168,17 @@ files:
|
|
|
167
168
|
- docs/decisions/029-a-panes-frame-is-identified-by-who-it-is.md
|
|
168
169
|
- docs/decisions/030-a-panes-src-belongs-to-turbo.md
|
|
169
170
|
- docs/decisions/031-a-subclass-inherits-the-dashboard.md
|
|
171
|
+
- docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md
|
|
172
|
+
- docs/decisions/033-a-snapshot-is-told-who-owns-it.md
|
|
173
|
+
- docs/decisions/034-janela-will-not-freeze-a-scope-the-host-has-not-named.md
|
|
174
|
+
- docs/decisions/035-a-check-does-what-janela-does-or-says-what-it-saw.md
|
|
175
|
+
- docs/decisions/036-janela-publishes-what-a-theme-may-target.md
|
|
176
|
+
- docs/decisions/037-what-1-0-means.md
|
|
170
177
|
- docs/decisions/INDEX.md
|
|
171
178
|
- docs/multi-tenancy.md
|
|
172
179
|
- docs/naming.md
|
|
180
|
+
- docs/roadmap.md
|
|
181
|
+
- docs/theming.md
|
|
173
182
|
- lib/janela.rb
|
|
174
183
|
- lib/janela/definition.rb
|
|
175
184
|
- lib/janela/dimension.rb
|
|
@@ -190,14 +199,24 @@ metadata:
|
|
|
190
199
|
bug_tracker_uri: https://github.com/retail-tasker/janela/issues
|
|
191
200
|
rubygems_mfa_required: 'true'
|
|
192
201
|
post_install_message: |
|
|
193
|
-
Janela 0.
|
|
194
|
-
|
|
195
|
-
that declares a janela block. Every named subclass of one is now
|
|
196
|
-
registered and addressable on its own route key, and Janela.definitions
|
|
197
|
-
returns one entry per subclass where a form offering a choice of model
|
|
198
|
-
previously showed one.
|
|
202
|
+
Janela 0.8.0 raises where it used to do nothing. One thing to act on,
|
|
203
|
+
and one to read.
|
|
199
204
|
|
|
200
|
-
|
|
205
|
+
1. If you set Janela.parent_controller anywhere but an initializer, it
|
|
206
|
+
now raises instead of being ignored in silence. config.to_prepare
|
|
207
|
+
and config.after_initialize are both too late, and the README's own
|
|
208
|
+
layout recipe is a to_prepare block. Until now your dashboards kept
|
|
209
|
+
inheriting whatever was named first, so your authentication and
|
|
210
|
+
policy_scope were not the ones you wrote (ADR 035).
|
|
211
|
+
|
|
212
|
+
2. bin/rails janela:doctor reports findings it could not reach before,
|
|
213
|
+
including a Pundit host with no policy for Janela::Frame or
|
|
214
|
+
Janela::Snapshot, which used to pass the doctor and raise on every
|
|
215
|
+
request. Expect things that were always true and never printed.
|
|
216
|
+
|
|
217
|
+
Also: the class names on Janela's own pages are no longer public API.
|
|
218
|
+
They are scoped under janela-page and may change in any release. What
|
|
219
|
+
your own markup contains is unchanged and is listed in docs/theming.md.
|
|
201
220
|
|
|
202
221
|
Steps: UPGRADING.md in this gem, or
|
|
203
222
|
https://github.com/retail-tasker/janela/blob/main/UPGRADING.md
|