janela 0.4.1 → 0.6.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.
@@ -0,0 +1,112 @@
1
+ ---
2
+ Date: 2026-09-18
3
+ Status: Accepted
4
+ Related: ADR 003, ADR 005, ADR 012, ADR 014, ADR 024
5
+ Superseded in part by: ADR 030
6
+ Triggers:
7
+ - changing how a pane's turbo frame is identified
8
+ - adding a parameter to a pane URL
9
+ - a host wanting a control over a pane's own settings
10
+ - a frame that does not update when it should
11
+ Topics: cross-filtering, panes, urls, host-integration
12
+ ---
13
+
14
+ # ADR 029: A Pane's Frame Is Identified by Who It Is, Not by What It Shows
15
+
16
+ ## Context
17
+
18
+ Issue #42 found that pointing a pane's turbo frame at a URL differing only
19
+ in `limit`, `granularity` or `as` makes Turbo fetch the response and then
20
+ silently do nothing: no render, no `frame-missing`, no console error, the
21
+ frame left showing the old numbers forever. Measured in a browser, not
22
+ inferred.
23
+
24
+ The cause is that `Query.turbo_frame_id` builds the id out of the query
25
+ itself, renderer, granularity and limit included, so the response comes
26
+ back wearing a different id from the frame that asked for it. Turbo has
27
+ nothing to reconcile and gives up quietly.
28
+
29
+ **The project has already solved this once, in the half that does not have
30
+ the bug.** A pane that is a record uses `Pane#turbo_frame_id`, which is
31
+ `janela_pane_#{id}`, and the comment on it says exactly why:
32
+
33
+ > The DOM id is the row, not the query it runs: two rows in one frame may
34
+ > show the same measure by the same dimension, and a fingerprint of the
35
+ > query would give them the same turbo frame for Turbo to replace.
36
+
37
+ That is ADR 014's reasoning, and it is right. A record-backed pane can
38
+ change its renderer, its granularity and its limit all day and its frame
39
+ id never moves, because the id says who the pane is rather than what it
40
+ is currently showing.
41
+
42
+ The helper path has no row to point at, so it fingerprints the query
43
+ instead. The fingerprint is not arbitrary: without it, two `janela_pane`
44
+ calls for the same measure by the same dimension would collide, and one
45
+ would replace the other. So the fingerprint solves a real problem and
46
+ creates this one.
47
+
48
+ The reason this has not bitten the library itself is worth stating: a
49
+ click changes filters, and filters are deliberately not in the id, so
50
+ every navigation Janela performs keeps the id stable. It is only a host
51
+ reaching for the URL's other documented parameters that falls in, and
52
+ what it gets is stale numbers with no error, which is the worst failure
53
+ this library has (ADR 003).
54
+
55
+ ## Decision
56
+
57
+ **`janela_pane` accepts an `id:`, and a pane given one is identified by
58
+ it rather than by a fingerprint of its query.**
59
+
60
+ 1. The fingerprint stays the default. It is correct for the common case,
61
+ a page of unlike panes with no controls over them, and it is what
62
+ keeps two alike panes apart.
63
+ 2. A host that wants to change a pane's settings in place names that
64
+ frame itself. The id is then stable by construction, because it comes
65
+ from the host rather than from the query, and Turbo reconciles
66
+ normally. This is the same move ADR 012 made for a record-backed pane,
67
+ made available to a pane that is not a record.
68
+ 3. The response wears the id the frame asked for rather than one derived
69
+ again from the query. This needs no new surface at all: Turbo already
70
+ sends the requesting frame's id in the `Turbo-Frame` header, and
71
+ turbo-rails exposes it as `turbo_frame_request_id`. So a pane rendered
72
+ into a frame request answers to the frame that asked, and a pane
73
+ rendered any other way keeps deriving its own id as it does now. The
74
+ pane URL does not grow a parameter, which was the first thing this
75
+ decision reached for and did not need.
76
+ 4. The failure is documented rather than left to be discovered. A host
77
+ that changes a pane's URL without naming its frame gets the silent
78
+ staleness described above, and the README says so where it describes
79
+ the pane URL's parameters.
80
+
81
+ ## What this turns down
82
+
83
+ **Taking renderer, granularity and limit out of the fingerprint.** It
84
+ would fix #42 and reintroduce the collision ADR 014 avoided: the gallery
85
+ renders the same measure by the same dimension as a bar and as a line on
86
+ one page, and those two must not share a frame. The fingerprint is doing
87
+ real work.
88
+
89
+ **Leaving it to every host to fetch and swap the frame themselves**, which
90
+ is what the demo's gallery does today and what proved the diagnosis. It
91
+ works, and it asks each host to write the same Stimulus controller and to
92
+ know a thing about Turbo's id matching that nothing told them. ADR 001
93
+ says ship the load-bearing 5%: a frame that updates when its URL changes
94
+ is inside that, and a host reimplementing frame reconciliation is not.
95
+ (Superseded in part by ADR 030: a frame does not update when its URL
96
+ changes, because `src` is not a channel a host can speak through. What is
97
+ inside the 5% is a pane that goes where it is asked to, which is the same
98
+ thing said correctly.)
99
+
100
+ ## Consequences
101
+
102
+ `janela_pane` grows one optional keyword argument, and a host that never
103
+ passes it sees no change at all. The demo's gallery control becomes
104
+ smaller, since the frame can be navigated rather than swapped by hand,
105
+ and that is the check on whether this decision is right: if the control
106
+ does not get simpler, the decision was wrong.
107
+
108
+ Two alike panes given the same `id` by a host would collide, which the
109
+ fingerprint prevented automatically. That is the cost of letting a host
110
+ name things, it is the same cost a host already carries for every DOM id
111
+ it writes, and the doctor is the place to notice it if it turns out to
112
+ happen.
@@ -0,0 +1,93 @@
1
+ ---
2
+ Date: 2026-09-18
3
+ Status: Accepted
4
+ Related: ADR 003, ADR 005, ADR 024, ADR 029
5
+ Supersedes: part of ADR 029
6
+ Triggers:
7
+ - a host changing what a pane shows from JavaScript
8
+ - writing to a turbo frame's src from anything but Turbo
9
+ - adding a data attribute the frame controller reads
10
+ - anything that would reintroduce reading intent out of the DOM
11
+ Topics: cross-filtering, panes, host-integration, javascript
12
+ ---
13
+
14
+ # ADR 030: A Pane's src Belongs to Turbo, So a Host Talks to the Frame
15
+
16
+ ## Context
17
+
18
+ ADR 029 gave a host a way to name a pane's frame so it could be
19
+ reconfigured in place, and said that "a frame that updates when its URL
20
+ changes is inside" the load-bearing core. Building it showed that is not
21
+ true, and the reason is a decision this project already made.
22
+
23
+ #33 established that a frame's `src` does not describe what that frame
24
+ is showing. Turbo writes `src` back onto a frame when a response lands,
25
+ including a late one for a request that has since been superseded. So
26
+ `janela--frame` keeps its own record of what was asked for and reverts
27
+ anything that does not match it:
28
+
29
+ > The request for what Janela last asked for wins, and any other is
30
+ > reverted. What was asked for is the only honest rule (#33).
31
+
32
+ That rule is right, and its consequence was not drawn at the time: if
33
+ the controller's own record is the only trustworthy statement of intent,
34
+ then `src` is no longer a channel a host can speak through. A host that
35
+ follows ADR 029, names a pane and writes `frame.src`, has its request
36
+ aborted and the frame put back, with no error and the old numbers still
37
+ on screen. That is the failure #42 was about, reached by following the
38
+ instructions written to fix #42.
39
+
40
+ There is no way to tell a host's `src` write from Turbo's. Any attempt,
41
+ a mutation observer, a flag, a heuristic on the attribute, re-infers
42
+ intent from the DOM, which is exactly what #33 removed because it
43
+ produced wrong numbers.
44
+
45
+ And the record is not one attribute. A pane also carries the base URL
46
+ the controller rebuilds from on the next click, so a host writing the
47
+ record by hand has to write two attributes in the right order, and
48
+ reapply the frame's current filters itself. Only the frame controller
49
+ knows those filters. The demo's own gallery control got that wrong:
50
+ reconfigure a pane while a filter is active and it refetches unfiltered
51
+ while every pane beside it stays filtered (#43).
52
+
53
+ ## Decision
54
+
55
+ **A host changes what a pane shows by asking `janela--frame`, and never
56
+ by writing `src` or a data attribute.**
57
+
58
+ The controller already does this for itself when the frame's filters
59
+ change. Making that reachable is extraction rather than new surface: one
60
+ way to ask a pane to go to a different query, which records what was
61
+ asked, reapplies the frame's filters and sets `src` in the order the
62
+ guard expects.
63
+
64
+ Three things follow.
65
+
66
+ 1. **The data attributes are private.** `janelaAsked` and `janelaSrc` are
67
+ how the controller remembers, not an interface. A host that writes
68
+ them is relying on something that may change.
69
+ 2. **A reconfigured pane stays cross-filtered.** Reapplying the frame's
70
+ current filters is part of repointing, not something a caller
71
+ remembers, because forgetting it shows numbers for the wrong filter
72
+ state and says nothing (ADR 003).
73
+ 3. **`id:` is still necessary.** ADR 029 stands: without a stable frame
74
+ id there is nothing to repoint. It is necessary and it was not
75
+ sufficient, which is what this records.
76
+
77
+ ## Consequences
78
+
79
+ ADR 029's claim that a frame updates when its URL changes is untrue as
80
+ written, and this supersedes that sentence rather than the decision. The
81
+ `id:` keyword and answering a frame request with the id Turbo sent are
82
+ both correct and stay.
83
+
84
+ A host reconfiguring a pane writes one call instead of three writes it
85
+ was never told about, and gets the filter behaviour right by default
86
+ rather than by knowing to. The demo's gallery control is the check, the
87
+ same way it was for ADR 029: it should get smaller again, and its filter
88
+ bug should disappear rather than be fixed separately.
89
+
90
+ The cost is one public method on a Stimulus controller, which is a
91
+ surface this project did not have before. It earns itself by removing a
92
+ class of silent wrongness rather than by adding capability, which is the
93
+ kind of addition ADR 001 leaves room for.
@@ -0,0 +1,110 @@
1
+ ---
2
+ Date: 2026-09-19
3
+ Status: Accepted
4
+ Related: ADR 002, ADR 013, ADR 014, ADR 025, ADR 028
5
+ Triggers:
6
+ - subclassing a model that declares a janela block
7
+ - changing what is addressable over HTTP
8
+ - changing how the registry is populated
9
+ - adding to the Ransack allowlist a model gets from Janela
10
+ Topics: configuration, urls, security, scope
11
+ ---
12
+
13
+ # ADR 031: A Subclass Inherits the Dashboard Its Parent Declared
14
+
15
+ ## Context
16
+
17
+ `janela` stores its definition in a plain class instance variable, so a
18
+ subclass of a model that declares one gets `nil` from `.janela` and is
19
+ not in the registry (#11).
20
+
21
+ Reproducing it found something the issue did not say. The subclass does
22
+ inherit the Ransack allowlist, because
23
+ `define_janela_ransack_allowlist` defines singleton methods on the
24
+ parent and singleton methods inherit down the singleton class chain:
25
+
26
+ ```
27
+ subclass .janela: nil
28
+ subclass ransackable: ["status", "channel", "placed_on"]
29
+ ```
30
+
31
+ So a subclass is already half declared: filterable on the parent's
32
+ dimensions, with no definition behind it saying what those dimensions
33
+ are. Neither half was decided. One was written and the other happened.
34
+
35
+ The instinct is to walk the ancestors in `janela`, which fixes `.janela`
36
+ and does not fix the bug. What a host wants from an STI subclass is a
37
+ dashboard of it, and that needs the subclass registered:
38
+
39
+ ```ruby
40
+ janela_pane PaidOrder, :revenue # src is /paid_orders/revenue
41
+ ```
42
+
43
+ An inherited definition with no registration renders that pane and then
44
+ 404s it. That is worse than today, because today the failure is loud at
45
+ the call site and that one is a dead pane on a page.
46
+
47
+ So the question is not whether the definition inherits. It is whether
48
+ being a subclass makes a model addressable, and `lib/janela.rb` states
49
+ the current answer plainly:
50
+
51
+ > Only models that declare a janela block are addressable over HTTP
52
+
53
+ ## Decision
54
+
55
+ **A subclass inherits its parent's definition and is addressable on its
56
+ own route key. Declaring is what a family of classes does once.**
57
+
58
+ The argument that settles it is about data rather than URLs. An STI
59
+ subclass is a subset of its parent's rows. If the parent is addressable,
60
+ the subclass reveals no row the parent does not already total, and it is
61
+ read through the same host scope as everything else (ADR 014). So this
62
+ adds URL surface and no data surface, which is a much smaller thing than
63
+ the rule above makes it sound.
64
+
65
+ Three things follow.
66
+
67
+ 1. **The halves agree.** The definition inherits, the registration
68
+ inherits, and the Ransack allowlist keeps inheriting as it already
69
+ does. A subclass is either a Janela model or it is not, rather than
70
+ being one in the part nobody chose.
71
+ 2. **STI scoping is ActiveRecord's, not Janela's.** A definition whose
72
+ model is the subclass runs its query on that class and ActiveRecord
73
+ adds the `type` condition itself. Janela learns nothing about STI,
74
+ which is the right amount for it to know.
75
+ 3. **A subclass may still declare its own block**, and then it has its
76
+ own definition rather than its parent's, which is how a subclass says
77
+ its dashboard is different.
78
+
79
+ Registration cannot happen where declaration happens, because the
80
+ subclass does not exist when the parent declares. It happens when the
81
+ subclass is created.
82
+
83
+ ## What this turns down
84
+
85
+ **Inheriting nothing, and documenting that each subclass declares its
86
+ own.** It is the most conservative reading and it asks a host with five
87
+ STI types to retype the same measures and dimensions five times. ADR 002
88
+ spent Ransack's familiarity on the host deliberately; spending their
89
+ typing on a class hierarchy Rails already models is a worse trade.
90
+
91
+ **Inheriting the definition without registering.** Rejected above: a
92
+ helper that renders a pane which cannot load.
93
+
94
+ ## Consequences
95
+
96
+ The honest cost is not security, it is noise. `Janela.definitions` feeds
97
+ the form that offers a choice of model when an analyst adds a pane
98
+ (ADR 012), and a host with a dozen STI types will see a dozen entries
99
+ where it expected one. That is a real cost and it is the thing to watch:
100
+ if it becomes the complaint, the answer is a way for a family to say
101
+ which of its classes are worth offering, and that is a later decision
102
+ rather than a setting invented now.
103
+
104
+ The check on whether this decision is right is what a host writes. If
105
+ adding a dashboard for an STI subclass still takes anything beyond
106
+ creating the class, the decision did not deliver.
107
+
108
+ The demo has no STI model, so proving this takes one: a `type` column on
109
+ a table and a subclass in the dummy. That is a fixture, and adding it is
110
+ part of the work rather than a reason to avoid it.
@@ -27,18 +27,18 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
27
27
  | **Authorisation** | 002, 003, 004, 009, 017, 019, 022 |
28
28
  | **Performance & storage** | 007, 017, 025 |
29
29
  | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025 |
30
- | **Layouts & views** | 011, 012, 016, 018, 020 |
31
- | **CSS & styling** | 016, 018, 023 |
32
- | **Frames, panes & persistence** | 012, 013, 014, 019 |
30
+ | **Layouts & views** | 011, 012, 016, 018, 020, 027 |
31
+ | **CSS & styling** | 016, 018, 023, 026, 027 |
32
+ | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030 |
33
33
  | **Naming rule** | 014, 023 |
34
- | **JavaScript delivery & charts** | 004, 006 |
34
+ | **JavaScript delivery & charts** | 004, 006, 026 |
35
35
  | **Time dimensions** | 006, 025 |
36
36
  | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025 |
37
- | **Snapshots & publishing** | 009, 020 |
37
+ | **Snapshots & publishing** | 009, 020, 028 |
38
38
  | **AI agents & guidance** | 010, 015, 021 |
39
39
  | **Releases & upgrades** | 015, 021 |
40
40
  | **Accessibility & keyboard** | 024 |
41
- | **Security** | 003, 025 |
41
+ | **Security** | 003, 025, 028, 031 |
42
42
  | **Testing** | 003 |
43
43
 
44
44
  ## Chronological
@@ -70,7 +70,13 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
70
70
  | 023 | Vitral Is a Theme, Not the Stylesheet | 2026-09-16 | Accepted |
71
71
  | 024 | Selecting More Than One Value | 2026-09-16 | Accepted |
72
72
  | 025 | Janela Bounds What a Filter Can Ask For | 2026-09-17 | Accepted |
73
+ | 026 | A Renderer Is the Seam, and HTML Comes First | 2026-09-18 | Accepted |
74
+ | 027 | The Gallery Is a Host Page, Built from the Gem's Helpers | 2026-09-18 | Accepted |
75
+ | 028 | The Predicate List ADR 025 Named Was Not Quite Right | 2026-09-18 | Accepted |
76
+ | 029 | A Pane's Frame Is Identified by Who It Is, Not by What It Shows | 2026-09-18 | Accepted |
77
+ | 030 | A Pane's src Belongs to Turbo, So a Host Talks to the Frame | 2026-09-18 | Accepted |
78
+ | 031 | A Subclass Inherits the Dashboard Its Parent Declared | 2026-09-19 | Accepted |
73
79
 
74
80
  ## Next number
75
81
 
76
- Next ADR: 026
82
+ Next ADR: 032
@@ -1,11 +1,25 @@
1
1
  module Janela
2
2
  class Definition
3
+ # ADR 007's existing ceiling on a limit, reused by ADR 025 as the default
4
+ # applied when a host asks for none, and as the bound on one filter's values.
5
+ MAXIMUM = 1000
6
+
3
7
  attr_reader :model, :measures, :dimensions
4
8
 
5
- def initialize(model)
9
+ def initialize(model, &block)
6
10
  @model = model
7
11
  @measures = {}
8
12
  @dimensions = {}
13
+ @block = block
14
+ instance_eval(&block) if block
15
+ end
16
+
17
+ # The same declaration read against another model, which is how a subclass
18
+ # inherits a dashboard: its measures and dimensions are its parent's, and
19
+ # the queries they run are its own, because ActiveRecord adds the type
20
+ # condition to a relation on the subclass (ADR 031).
21
+ def for(model)
22
+ self.class.new(model, &@block)
9
23
  end
10
24
 
11
25
  def measure(name, **aggregate)
@@ -35,7 +49,7 @@ module Janela
35
49
  measure.apply(buckets).transform_keys { |bucket| dimension.label(bucket, granularity) }
36
50
  else
37
51
  grouped = relation.group(dimension.attribute).order(Arel.sql("#{measure.sql_alias} DESC"))
38
- grouped = grouped.limit(limit!(limit)) if limit
52
+ grouped = grouped.limit(limit ? limit!(limit) : MAXIMUM)
39
53
  measure.apply(grouped).transform_keys { |value| value.nil? ? Dimension::NONE : value }
40
54
  end
41
55
  end
@@ -64,7 +78,7 @@ module Janela
64
78
 
65
79
  def limit!(value)
66
80
  limit = Integer(value, exception: false)
67
- raise BadRequest, "limit must be a whole number from 1 to 1000, got #{value.inspect}" unless limit&.between?(1, 1000)
81
+ raise BadRequest, "limit must be a whole number from 1 to #{MAXIMUM}, got #{value.inspect}" unless limit&.between?(1, MAXIMUM)
68
82
  limit
69
83
  end
70
84
 
@@ -82,6 +96,8 @@ module Janela
82
96
 
83
97
  search = relation.ransack(params)
84
98
  reject_dropped_filters!(search, params)
99
+ reject_disallowed_predicates!(search)
100
+ reject_oversized_filters!(search)
85
101
  search.result
86
102
  end
87
103
 
@@ -95,5 +111,35 @@ module Janela
95
111
  raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
96
112
  "Declare a janela dimension, or add it to ransackable_attributes."
97
113
  end
114
+
115
+ # An allowed attribute still reaches every predicate Ransack knows,
116
+ # including _matches, an arbitrary LIKE pattern (ADR 025). A dashboard
117
+ # asks in only the predicates its kind of dimension needs.
118
+ def reject_disallowed_predicates!(search)
119
+ by_ransack_name = dimensions.values.index_by(&:ransack_name)
120
+
121
+ search.conditions.each do |condition|
122
+ condition.attributes.each do |attribute|
123
+ dimension = by_ransack_name.fetch(attribute.name)
124
+ next if dimension.allowed_predicates.include?(condition.predicate_name)
125
+
126
+ allowed = dimension.allowed_predicates.map { |predicate| "#{attribute.name}_#{predicate}" }
127
+ raise BadRequest, "#{model} does not allow #{attribute.name}_#{condition.predicate_name}. " \
128
+ "This dimension allows #{allowed.join(', ')}."
129
+ end
130
+ end
131
+ end
132
+
133
+ # A click writes _in (ADR 024), which takes an array Ransack does not
134
+ # otherwise bound. 5001 values answered rather than being refused.
135
+ def reject_oversized_filters!(search)
136
+ search.conditions.each do |condition|
137
+ next unless condition.predicate.wants_array
138
+ next if condition.values.size <= MAXIMUM
139
+
140
+ raise BadRequest, "#{model} does not allow a filter to carry more than #{MAXIMUM} values, " \
141
+ "got #{condition.values.size} for #{condition.attributes.map(&:name).join(', ')}."
142
+ end
143
+ end
98
144
  end
99
145
  end
@@ -2,6 +2,14 @@ module Janela
2
2
  class Dimension
3
3
  GRANULARITIES = %w[hour day week month quarter year].freeze
4
4
 
5
+ # What a click produces (ADR 024), plus not_null: excluding the null
6
+ # group is documented, tested behaviour ADR 025 did not measure and would
7
+ # otherwise silently break.
8
+ CATEGORICAL_PREDICATES = %w[eq in null not_null].freeze
9
+
10
+ # A time dimension additionally narrows a range (ADR 006).
11
+ TIME_PREDICATES = (CATEGORICAL_PREDICATES + %w[gteq gt lteq lt]).freeze
12
+
5
13
  # A group of rows whose dimension is null. Labelled rather than blank, and
6
14
  # filtered with Ransack's null predicate rather than an empty string.
7
15
  NONE = "(none)".freeze
@@ -41,6 +49,11 @@ module Janela
41
49
  !granularity.nil?
42
50
  end
43
51
 
52
+ # Which Ransack predicates a filter on this dimension may use (ADR 025).
53
+ def allowed_predicates
54
+ time? ? TIME_PREDICATES : CATEGORICAL_PREDICATES
55
+ end
56
+
44
57
  def attribute
45
58
  klass.arel_table[column]
46
59
  end
data/lib/janela/doctor.rb CHANGED
@@ -11,7 +11,7 @@ module Janela
11
11
  # silences a check by that name (ADR 021).
12
12
  CHECKS = %i[stale_identifiers unmounted_engine unmigrated_tables unregistered_controllers
13
13
  through_dimensions_without_an_allowlist frames_nobody_will_own
14
- unauthenticated_endpoints].freeze
14
+ unauthenticated_endpoints hardcoded_disallowed_predicates].freeze
15
15
 
16
16
  # Identifiers a previous version of Janela used, and what replaced them.
17
17
  RENAMED = {
@@ -153,6 +153,34 @@ module Janela
153
153
  end
154
154
  end
155
155
 
156
+ # A filter read from a URL param is runtime state the doctor cannot see,
157
+ # but one written into the host's own Ruby is source like any other
158
+ # identifier this doctor already greps for (ADR 015, ADR 021, ADR 025).
159
+ def hardcoded_disallowed_predicates
160
+ janela_models.flat_map do |model|
161
+ model.janela.dimensions.values.flat_map { |dimension| disallowed_uses(model, dimension) }
162
+ end
163
+ end
164
+
165
+ def disallowed_uses(model, dimension)
166
+ pattern = /\b#{Regexp.escape(dimension.ransack_name)}(_\w+)/
167
+ source_files.flat_map do |file|
168
+ content = file.read
169
+ content.scan(pattern).flatten.uniq.filter_map do |candidate|
170
+ predicate = Ransack::Predicate.detect_from_string(candidate.dup)
171
+ next unless predicate
172
+ next if dimension.allowed_predicates.include?(predicate)
173
+
174
+ key = "#{dimension.ransack_name}_#{predicate}"
175
+ allowed = dimension.allowed_predicates.map { |p| "#{dimension.ransack_name}_#{p}" }
176
+ Finding.new(severity: :error,
177
+ summary: "#{model} does not allow #{key}",
178
+ detail: " #{file.relative_path_from(@root)} filters #{model} on #{key}, which Janela now " \
179
+ "refuses (ADR 025). Allowed here: #{allowed.join(', ')}.")
180
+ end
181
+ end
182
+ end
183
+
156
184
  # A host whose policy filters frames by owner, but which never tells
157
185
  # Janela what owns a new one, creates frames its own scope then hides.
158
186
  # The failure is silent, and a typo in the method name looks the same as
data/lib/janela/model.rb CHANGED
@@ -1,25 +1,61 @@
1
1
  module Janela
2
2
  module Model
3
+ # Dimensions are the only things Janela filters on, so they are the
4
+ # Ransack allowlist. This asks for the definition when it is called rather
5
+ # than closing over one, so a subclass answers with the definition it
6
+ # reports, whether that is its parent's or one it declared itself. The two
7
+ # halves cannot then disagree, which is what they did before ADR 031: a
8
+ # subclass inherited this list and reported no definition behind it.
9
+ module RansackAllowlist
10
+ def ransackable_attributes(_auth_object = nil)
11
+ janela.ransackable_attributes
12
+ end
13
+
14
+ def ransackable_associations(_auth_object = nil)
15
+ janela.ransackable_associations
16
+ end
17
+ end
18
+
3
19
  def janela(&block)
4
- return @janela_definition unless block
20
+ return @janela_definition ||= inherited_janela_definition unless block
5
21
 
6
- @janela_definition = Definition.new(self)
7
- @janela_definition.instance_eval(&block)
22
+ @janela_definition = Definition.new(self, &block)
8
23
  define_janela_ransack_allowlist
9
24
  Janela.register(self)
10
25
  @janela_definition
11
26
  end
12
27
 
28
+ # A subclass cannot be registered where its parent declares, because it
29
+ # does not exist yet, so it registers as it is created (ADR 031). An
30
+ # anonymous class has no route key to be addressed by; naming it is the
31
+ # host's move and declaring on it is the host's other one.
32
+ def inherited(subclass)
33
+ super
34
+ Janela.register_subclass(subclass) if subclass.name && janela
35
+ end
36
+
13
37
  private
14
- # Dimensions are the only things Janela filters on, so they are the
15
- # Ransack allowlist. A model that already declares its own allowlist
16
- # keeps it.
38
+ # What a subclass inherits is the declaration, not the definition
39
+ # object. A definition holds the model it queries, so a subclass handed
40
+ # its parent's would report the right dashboard and then total the
41
+ # parent's rows behind it.
42
+ def inherited_janela_definition
43
+ superclass.janela&.for(self) if superclass.respond_to?(:janela)
44
+ end
45
+
46
+ # A model that already answers for itself keeps its answer, whether it
47
+ # said so here or on a class above. Ransack's own default lives on
48
+ # ActiveRecord::Base, so anything nearer than that was somebody's
49
+ # decision and is not ours to replace.
17
50
  def define_janela_ransack_allowlist
18
- return if singleton_class.method_defined?(:ransackable_attributes, false)
51
+ return if janela_ransack_allowlist_answered?
52
+
53
+ extend RansackAllowlist
54
+ end
19
55
 
20
- definition = @janela_definition
21
- define_singleton_method(:ransackable_attributes) { |_auth_object = nil| definition.ransackable_attributes }
22
- define_singleton_method(:ransackable_associations) { |_auth_object = nil| definition.ransackable_associations }
56
+ def janela_ransack_allowlist_answered?
57
+ owner = singleton_class.instance_method(:ransackable_attributes).owner
58
+ !ActiveRecord::Base.singleton_class.ancestors.include?(owner)
23
59
  end
24
60
  end
25
61
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.4.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -37,9 +37,10 @@ module Janela
37
37
  # is for (ADR 021).
38
38
  mattr_accessor :silenced_checks, default: []
39
39
 
40
- # Only models that declare a janela block are addressable over HTTP, keyed by
41
- # the route key that appears in pane URLs (orders, sales_orders). Names are
42
- # stored rather than classes so a reloaded model leaves nothing stale behind.
40
+ # A model that declares a janela block is addressable over HTTP, and so is a
41
+ # subclass of one, keyed by the route key that appears in pane URLs (orders,
42
+ # sales_orders). Names are stored rather than classes so a reloaded model
43
+ # leaves nothing stale behind.
43
44
  def self.registry
44
45
  @registry ||= {}
45
46
  end
@@ -48,6 +49,18 @@ module Janela
48
49
  registry[model.model_name.route_key] = model.name
49
50
  end
50
51
 
52
+ # A subclass registers itself as it is created (ADR 031), so unlike a
53
+ # declaration it is not a host writing a line of code. It never takes a
54
+ # route key another class already holds: a host that gives a subclass its
55
+ # parent's model_name, so the two share a route and a form, would otherwise
56
+ # find the parent's URL answering with a subset of its rows.
57
+ def self.register_subclass(model)
58
+ route_key = model.model_name.route_key
59
+ return if registry.key?(route_key) && registry[route_key] != model.name
60
+
61
+ register(model)
62
+ end
63
+
51
64
  # Every model that declares a janela block, for a form that offers a choice
52
65
  # of them. Eager loading first, because a model nobody has referenced yet has
53
66
  # not registered. A name that no longer resolves is left out rather than
@@ -66,4 +79,19 @@ module Janela
66
79
 
67
80
  class_name.constantize.janela
68
81
  end
82
+
83
+ # What Janela can draw, so a gallery asks rather than reaching into
84
+ # Janela::Query::RENDERERS, Janela::Dimension::GRANULARITIES or
85
+ # Janela::Pane::OFFERED_LIMITS itself (ADR 027, #39).
86
+ def self.renderers
87
+ Query::RENDERERS
88
+ end
89
+
90
+ def self.granularities
91
+ Dimension::GRANULARITIES
92
+ end
93
+
94
+ def self.offered_limits
95
+ Pane::OFFERED_LIMITS
96
+ end
69
97
  end