janela 0.2.0 → 0.3.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +203 -21
  4. data/UPGRADING.md +136 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +16 -8
  6. data/app/assets/javascripts/janela/{dashboard_controller.js → frame_controller.js} +11 -4
  7. data/app/assets/stylesheets/janela.css +163 -0
  8. data/app/controllers/janela/application_controller.rb +45 -0
  9. data/app/controllers/janela/frames_controller.rb +57 -0
  10. data/app/controllers/janela/panes_controller.rb +66 -13
  11. data/app/controllers/janela/queries_controller.rb +17 -0
  12. data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
  13. data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +25 -9
  14. data/app/models/janela/frame.rb +28 -0
  15. data/app/models/janela/pane.rb +107 -85
  16. data/app/models/janela/query.rb +149 -0
  17. data/app/models/janela/snapshot.rb +6 -6
  18. data/app/views/janela/frames/_card.html.erb +7 -0
  19. data/app/views/janela/frames/_form.html.erb +23 -0
  20. data/app/views/janela/frames/_frame.html.erb +4 -0
  21. data/app/views/janela/frames/_pane.html.erb +12 -0
  22. data/app/views/janela/frames/edit.html.erb +25 -0
  23. data/app/views/janela/frames/index.html.erb +15 -0
  24. data/app/views/janela/frames/new.html.erb +5 -0
  25. data/app/views/janela/frames/show.html.erb +9 -0
  26. data/app/views/janela/panes/_form.html.erb +58 -0
  27. data/app/views/janela/panes/_row.html.erb +12 -0
  28. data/app/views/janela/panes/edit.html.erb +5 -0
  29. data/app/views/janela/panes/new.html.erb +22 -0
  30. data/app/views/janela/panes/show.html.erb +3 -44
  31. data/app/views/janela/queries/_query.html.erb +47 -0
  32. data/app/views/janela/queries/show.html.erb +4 -0
  33. data/app/views/janela/shared/_errors.html.erb +7 -0
  34. data/app/views/layouts/janela/application.html.erb +26 -0
  35. data/config/importmap.rb +1 -1
  36. data/config/locales/en.yml +64 -0
  37. data/config/routes.rb +15 -2
  38. data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
  39. data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
  40. data/docs/decisions/001-built-to-be-forked.md +4 -0
  41. data/docs/decisions/005-pane-urls-and-mount-path.md +4 -1
  42. data/docs/decisions/009-snapshots.md +5 -2
  43. data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +134 -0
  44. data/docs/decisions/011-panes-do-not-render-in-the-host-layout.md +81 -0
  45. data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
  46. data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
  47. data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
  48. data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
  49. data/docs/decisions/016-the-styling-vocabulary.md +100 -0
  50. data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
  51. data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
  52. data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
  53. data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
  54. data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
  55. data/docs/decisions/INDEX.md +24 -5
  56. data/docs/multi-tenancy.md +175 -0
  57. data/lib/janela/definition.rb +23 -9
  58. data/lib/janela/dimension.rb +5 -1
  59. data/lib/janela/doctor.rb +219 -0
  60. data/lib/janela/engine.rb +15 -2
  61. data/lib/janela/measure.rb +68 -4
  62. data/lib/janela/version.rb +1 -1
  63. data/lib/janela.rb +24 -1
  64. data/lib/tasks/janela.rake +6 -0
  65. metadata +57 -4
@@ -0,0 +1,175 @@
1
+ # Fitting Janela into a multi tenant application
2
+
3
+ Janela holds no tenancy of its own. There is no tenant setting, no
4
+ default scope of ours on your models, and nothing to configure. The
5
+ engine asks your application two questions and does what it is told.
6
+
7
+ | Question | How your application answers | If it says nothing |
8
+ | --- | --- | --- |
9
+ | What may this request read? | `policy_scope(model)` on the controller Janela inherits | Everything: `model.all` |
10
+ | What owns a frame being created? | `janela_frame_owner` on the same controller | Nothing: a nil owner |
11
+
12
+ Both are ordinary methods on your `ApplicationController`, found by
13
+ duck typing. Pundit defines the first for you. Anything else, you
14
+ define in about five lines. ADR 019 has the reasoning for asking
15
+ rather than being configured: ownership is per-request state, and a
16
+ setting cannot hold it.
17
+
18
+ ## What goes through your scope
19
+
20
+ Everything. There is no path through the engine that reads a record
21
+ without asking first:
22
+
23
+ - The index of frames, and each frame page.
24
+ - Every editing action on a frame or a pane, so another tenant's frame
25
+ is a 404 to rename or delete as much as to read.
26
+ - Every pane's own query, on Janela's pages and inline in yours. The
27
+ measure is calculated over `policy_scope(Order)`, not over `Order`.
28
+ - An ad hoc pane URL, which is the same query by another route.
29
+ - A snapshot, read through `policy_scope(Janela::Snapshot)`.
30
+
31
+ A pane rendered inline in your own page runs in your request, so the
32
+ same scope applies there as in the engine's controllers. Nothing is
33
+ calculated in a background context where the current tenant would have
34
+ gone missing.
35
+
36
+ ## With Pundit
37
+
38
+ Write a policy for Janela's own record. The class name is namespaced,
39
+ because the model is:
40
+
41
+ ```ruby
42
+ # app/policies/janela/frame_policy.rb
43
+ module Janela
44
+ class FramePolicy < ApplicationPolicy
45
+ class Scope < ApplicationPolicy::Scope
46
+ def resolve
47
+ scope.where(owner: Current.account)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ ```
53
+
54
+ ```ruby
55
+ class ApplicationController < ActionController::Base
56
+ include Pundit::Authorization
57
+
58
+ private
59
+ def janela_frame_owner
60
+ Current.account
61
+ end
62
+ end
63
+ ```
64
+
65
+ Your own models keep the policies they already have. Janela calls
66
+ `policy_scope(Order)` and gets whatever `OrderPolicy::Scope` returns.
67
+
68
+ ## With acts_as_tenant
69
+
70
+ Your own models are already scoped, so the only thing Janela needs
71
+ told about is its own tables. Define `policy_scope` as the adapter:
72
+
73
+ ```ruby
74
+ class ApplicationController < ActionController::Base
75
+ set_current_tenant_through_filter
76
+ before_action :set_tenant
77
+
78
+ private
79
+ def policy_scope(model)
80
+ case model.name
81
+ when "Janela::Frame" then model.where(owner: ActsAsTenant.current_tenant)
82
+ else model.all # acts_as_tenant has already scoped your own models
83
+ end
84
+ end
85
+
86
+ def janela_frame_owner
87
+ ActsAsTenant.current_tenant
88
+ end
89
+ end
90
+ ```
91
+
92
+ ## With CanCanCan, or with your own
93
+
94
+ The same adapter, pointed at whatever you use:
95
+
96
+ ```ruby
97
+ def policy_scope(model)
98
+ model.accessible_by(current_ability)
99
+ end
100
+ ```
101
+
102
+ Janela never asks how the answer was arrived at. A method that takes a
103
+ class and returns a relation is the whole contract.
104
+
105
+ ## With one tenant
106
+
107
+ Define nothing. `policy_scope` is absent, every query runs over
108
+ `model.all`, and a frame is created with a nil owner because nothing
109
+ is filtering on one.
110
+
111
+ ## What owns a frame
112
+
113
+ `Janela::Frame belongs_to :owner, polymorphic: true, optional: true`.
114
+ It can point at an account, a team, a user or anything else you scope
115
+ by, and Janela reads nothing from it. It exists so your scope has a
116
+ column.
117
+
118
+ Return the tenant from `janela_frame_owner` and the engine assigns it
119
+ to a frame it creates. Skip that method while your scope filters by
120
+ owner and the analyst's new dashboard is saved with no owner, then
121
+ hidden by your own policy the instant it is saved. It looks like the
122
+ save failed silently. `bin/rails janela:doctor` reports this for you:
123
+ it asks your policy for a scope over frames and looks for an owner in
124
+ what comes back.
125
+
126
+ ## Snapshots are the rough edge
127
+
128
+ `janela_snapshots` has no owner column. A snapshot is a name, an
129
+ instant, the filters it was taken under and the results. So a multi
130
+ tenant application has to scope it by something else:
131
+
132
+ - Keep snapshots to one tenant, or to whoever publishes.
133
+ - Add your own column with a migration on `janela_snapshots` and scope
134
+ on that.
135
+ - Encode the tenant in the filters a snapshot is taken under, and
136
+ scope on the stored name.
137
+
138
+ None of those is as clean as a frame's owner. Giving a snapshot the
139
+ same polymorphic owner is [issue #32](https://github.com/retail-tasker/janela/issues/32).
140
+
141
+ ## Proving your wiring
142
+
143
+ Run the doctor first. It reads your application and reports the traps
144
+ that are visible from outside a request:
145
+
146
+ ```bash
147
+ bin/rails janela:doctor
148
+ ```
149
+
150
+ Then write the test that matters, which is the one that fails if a
151
+ scope is ever loosened. Create a frame as one tenant and ask for it as
152
+ another:
153
+
154
+ ```ruby
155
+ test "another tenant's frame is a 404" do
156
+ frame = Janela::Frame.create!(name: "Theirs", owner: accounts(:acme))
157
+
158
+ sign_in users(:globex_analyst)
159
+ get janela.frame_path(frame)
160
+
161
+ assert_response :not_found
162
+ end
163
+ ```
164
+
165
+ This repository's dummy application is a worked example of all of the
166
+ above, in
167
+ [test/dummy/app/controllers/application_controller.rb](https://github.com/retail-tasker/janela/blob/main/test/dummy/app/controllers/application_controller.rb),
168
+ scoped by a `?tenant=` parameter standing in for a session.
169
+
170
+ ## What Janela will never do
171
+
172
+ Add a tenant filter of its own. Your scope is the only one, so there
173
+ is nothing to double filter and nothing that looks enforced while
174
+ being unenforceable. Janela can be handed any relation through `on:`,
175
+ so a guarantee made here would be a guarantee it cannot keep.
@@ -9,7 +9,7 @@ module Janela
9
9
  end
10
10
 
11
11
  def measure(name, **aggregate)
12
- measures[name] = Measure.build(name, **aggregate)
12
+ measures[name] = Measure.build(name, model: model, **aggregate).tap { |measure| reject_boolean_column!(measure) }
13
13
  end
14
14
 
15
15
  def dimension(name, through: nil, column: nil, granularity: nil)
@@ -36,17 +36,35 @@ module Janela
36
36
  else
37
37
  grouped = relation.group(dimension.attribute).order(Arel.sql("#{measure.sql_alias} DESC"))
38
38
  grouped = grouped.limit(limit!(limit)) if limit
39
- measure.apply(grouped)
39
+ measure.apply(grouped).transform_keys { |value| value.nil? ? Dimension::NONE : value }
40
40
  end
41
41
  end
42
42
 
43
43
  def dimension!(name)
44
- dimensions.fetch(name) { raise Error, "#{model} has no janela dimension #{name.inspect}" }
44
+ dimensions.fetch(name) { raise NotFound, "#{model} has no janela dimension #{name.inspect}" }
45
+ end
46
+
47
+ def measure!(name)
48
+ measures.fetch(name) { raise NotFound, "#{model} has no janela measure #{name.inspect}" }
49
+ end
50
+
51
+ # ActiveRecord casts an aggregate back through the column's own type, so
52
+ # AVG over a boolean returns true rather than a ratio. Say so at
53
+ # declaration rather than rendering a meaningless pane.
54
+ def reject_boolean_column!(measure)
55
+ return unless measure.column && Measure::NUMERIC.include?(measure.aggregate)
56
+ return unless model.type_for_attribute(measure.column).type == :boolean
57
+
58
+ raise Error, "measure #{measure.name.inspect} takes #{measure.aggregate} of the boolean " \
59
+ "#{model}##{measure.column}, which ActiveRecord casts back to true or false. " \
60
+ "Declare dimension #{measure.column.inspect} instead and read the split."
61
+ rescue ActiveRecord::ActiveRecordError
62
+ nil # no database to ask yet; a query will raise on its own if it cannot run
45
63
  end
46
64
 
47
65
  def limit!(value)
48
66
  limit = Integer(value, exception: false)
49
- raise Error, "limit must be a whole number from 1 to 1000, got #{value.inspect}" unless limit&.between?(1, 1000)
67
+ raise BadRequest, "limit must be a whole number from 1 to 1000, got #{value.inspect}" unless limit&.between?(1, 1000)
50
68
  limit
51
69
  end
52
70
 
@@ -74,12 +92,8 @@ module Janela
74
92
  dropped = params.keys.reject { |key| applied.any? { |name| key.to_s.start_with?(name) } }
75
93
  return if dropped.empty?
76
94
 
77
- raise Error, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
95
+ raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
78
96
  "Declare a janela dimension, or add it to ransackable_attributes."
79
97
  end
80
-
81
- def measure!(name)
82
- measures.fetch(name) { raise Error, "#{model} has no janela measure #{name.inspect}" }
83
- end
84
98
  end
85
99
  end
@@ -2,6 +2,10 @@ module Janela
2
2
  class Dimension
3
3
  GRANULARITIES = %w[hour day week month quarter year].freeze
4
4
 
5
+ # A group of rows whose dimension is null. Labelled rather than blank, and
6
+ # filtered with Ransack's null predicate rather than an empty string.
7
+ NONE = "(none)".freeze
8
+
5
9
  LABELS = {
6
10
  "hour" => ->(t) { t.strftime("%Y-%m-%d %H:00") },
7
11
  "day" => ->(t) { t.strftime("%Y-%m-%d") },
@@ -29,7 +33,7 @@ module Janela
29
33
 
30
34
  def self.granularity!(value)
31
35
  value = value.to_s
32
- raise Error, "unknown granularity #{value.inspect}, use one of #{GRANULARITIES.join(', ')}" unless GRANULARITIES.include?(value)
36
+ raise BadRequest, "unknown granularity #{value.inspect}, use one of #{GRANULARITIES.join(', ')}" unless GRANULARITIES.include?(value)
33
37
  value
34
38
  end
35
39
 
@@ -0,0 +1,219 @@
1
+ module Janela
2
+ # Reads a host application and reports what it still has to do. Only ever
3
+ # reads and reports: the checks are the install and upgrade traps that
4
+ # experience says actually break a host (ADR 015).
5
+ class Doctor
6
+ # code is the check that produced the finding, set by the runner rather
7
+ # than by each check, so the two can never drift apart.
8
+ Finding = Struct.new(:severity, :summary, :detail, :code, keyword_init: true)
9
+
10
+ # Run in this order, and each one names the finding it produces: a host
11
+ # silences a check by that name (ADR 021).
12
+ CHECKS = %i[stale_identifiers unmounted_engine unmigrated_tables unregistered_controllers
13
+ through_dimensions_without_an_allowlist frames_nobody_will_own
14
+ unauthenticated_endpoints].freeze
15
+
16
+ # Identifiers a previous version of Janela used, and what replaced them.
17
+ RENAMED = {
18
+ "janela--dashboard" => "janela--frame",
19
+ "janela_dashboard" => "janela_frame",
20
+ "janela/dashboard_controller" => "janela/frame_controller",
21
+ "janela/dashboard_controller.js" => "janela/frame_controller.js",
22
+ "Janela::DashboardHelper" => "Janela::FramesHelper",
23
+ "Janela::PanesController" => "Janela::QueriesController",
24
+ "Janela::SnapshotPanesController" => "Janela::SnapshotQueriesController"
25
+ }.freeze
26
+
27
+ SEARCHED = %w[app config lib].freeze
28
+ READABLE = %w[.rb .erb .js .erb.html .html.erb .haml .slim .yml].freeze
29
+
30
+ def initialize(root)
31
+ @root = Pathname.new(root)
32
+ end
33
+
34
+ def report(out)
35
+ findings, silenced = all_findings.partition { |finding| !silenced?(finding) }
36
+
37
+ if findings.empty?
38
+ out.puts "Janela: nothing to fix."
39
+ else
40
+ out.puts "Janela found #{findings.size} #{'thing'.pluralize(findings.size)} to look at."
41
+ findings.each do |finding|
42
+ out.puts
43
+ out.puts "#{finding.severity.to_s.upcase} (#{finding.code}): #{finding.summary}"
44
+ out.puts finding.detail
45
+ end
46
+ out.puts
47
+ out.puts "Silence one you have judged a false alarm, in config/initializers/janela.rb:"
48
+ out.puts " Janela.silenced_checks = %w[#{findings.first.code}]"
49
+ out.puts
50
+ out.puts "Steps for a version upgrade are in UPGRADING.md."
51
+ end
52
+
53
+ # Said out loud every run, because a silence nobody remembers is how a
54
+ # real finding goes unread.
55
+ out.puts "Silenced: #{silenced.map(&:code).uniq.join(', ')}." if silenced.any?
56
+ findings.none? { |finding| finding.severity == :error }
57
+ end
58
+
59
+ def check
60
+ all_findings.reject { |finding| silenced?(finding) }
61
+ end
62
+
63
+ private
64
+ def all_findings
65
+ @all_findings ||= CHECKS.flat_map { |name| findings_from(name) }
66
+ end
67
+
68
+ # Struct responds to to_a, so a single finding is wrapped by hand rather
69
+ # than with Array(), which would take it apart into its members.
70
+ def findings_from(name)
71
+ found = send(name)
72
+ found = [ found ] unless found.is_a?(Array)
73
+ found.compact.each { |finding| finding.code = name.to_s.dasherize }
74
+ end
75
+
76
+ def silenced?(finding)
77
+ Janela.silenced_checks.map(&:to_s).include?(finding.code)
78
+ end
79
+
80
+ def stale_identifiers
81
+ RENAMED.filter_map do |old, new|
82
+ files = source_files.select { |file| file.read.include?(old) }
83
+ next if files.empty?
84
+
85
+ Finding.new(severity: :error,
86
+ summary: "#{old} is now #{new}",
87
+ detail: files.map { |file| " #{file.relative_path_from(@root)}" }.join("\n"))
88
+ end
89
+ end
90
+
91
+ def unmounted_engine
92
+ return if engine_mounted?
93
+
94
+ Finding.new(severity: :error,
95
+ summary: "Janela::Engine is not mounted",
96
+ detail: " Add to config/routes.rb, at whatever path suits you:\n" \
97
+ " mount Janela::Engine => \"/insights\"")
98
+ end
99
+
100
+ # The mount root serves an index of frames, so a host that upgrades
101
+ # without running the migrations finds an exception where its dashboards
102
+ # were. Only a host that mounts the engine needs the tables at all.
103
+ def unmigrated_tables
104
+ return unless engine_mounted?
105
+
106
+ missing = [ Janela::Frame, Janela::Pane, Janela::Snapshot ].reject(&:table_exists?).map(&:table_name)
107
+ return if missing.empty?
108
+
109
+ Finding.new(severity: :error,
110
+ summary: "#{missing.to_sentence} #{missing.one? ? 'is' : 'are'} missing",
111
+ detail: " Janela's own pages read these the moment the engine is mounted. Run:\n" \
112
+ " bin/rails janela:install:migrations\n" \
113
+ " bin/rails db:migrate")
114
+ rescue StandardError
115
+ nil # no database to ask yet
116
+ end
117
+
118
+ def engine_mounted?
119
+ Rails.application.routes.routes.any? { |route| route.app.respond_to?(:app) && route.app.app == Janela::Engine }
120
+ end
121
+
122
+ def unregistered_controllers
123
+ registered = source_files.any? { |file| file.read.include?("janela--frame") }
124
+ return if registered
125
+
126
+ Finding.new(severity: :error,
127
+ summary: "Janela's Stimulus controllers are not registered anywhere",
128
+ detail: " Without them a dashboard renders but nothing cross-filters, which\n" \
129
+ " looks like nothing happening at all. Register both:\n" \
130
+ " application.register(\"janela--frame\", JanelaFrameController)\n" \
131
+ " application.register(\"janela--chart\", JanelaChartController)")
132
+ end
133
+
134
+ # Ransack's allowlist is per class, so a dimension read through an
135
+ # association needs the associated model to allow the attribute too.
136
+ def through_dimensions_without_an_allowlist
137
+ janela_models.flat_map do |model|
138
+ model.janela.dimensions.values.select(&:through).filter_map do |dimension|
139
+ association = model.reflect_on_association(dimension.through)
140
+ next unless association
141
+
142
+ allowed = association.klass.ransackable_attributes.map(&:to_s)
143
+ next if allowed.include?(dimension.column.to_s)
144
+
145
+ Finding.new(severity: :error,
146
+ summary: "#{association.klass} does not allow filtering on #{dimension.column}",
147
+ detail: " #{model}'s #{dimension.name.inspect} dimension reads it through " \
148
+ "#{dimension.through.inspect}, and Ransack's allowlist is per class. Add to " \
149
+ "#{association.klass}:\n" \
150
+ " def self.ransackable_attributes(_auth_object = nil) = " \
151
+ "%w[#{(allowed + [ dimension.column.to_s ]).uniq.join(' ')}]")
152
+ end
153
+ end
154
+ end
155
+
156
+ # A host whose policy filters frames by owner, but which never tells
157
+ # Janela what owns a new one, creates frames its own scope then hides.
158
+ # The failure is silent, and a typo in the method name looks the same as
159
+ # not defining it, which is the cost of asking by duck typing (ADR 019).
160
+ def frames_nobody_will_own
161
+ parent = Janela.parent_controller.safe_constantize
162
+ return unless parent&.private_method_defined?(:policy_scope) || parent&.method_defined?(:policy_scope)
163
+ return if parent.private_method_defined?(:janela_frame_owner) || parent.method_defined?(:janela_frame_owner)
164
+ return unless Janela::Frame.table_exists? && scope_filters_frames_by_owner?(parent)
165
+
166
+ Finding.new(severity: :error,
167
+ summary: "#{parent} scopes frames by owner but defines no janela_frame_owner",
168
+ detail: " A frame created through Janela's own form would have no owner, and your\n" \
169
+ " own scope would then hide it. Define this on #{parent}:\n" \
170
+ " def janela_frame_owner\n" \
171
+ " Current.account # whatever your policy scope filters frames by\n" \
172
+ " end")
173
+ rescue StandardError
174
+ nil # no database or no policy to ask; nothing can be concluded
175
+ end
176
+
177
+ # Asking the policy rather than reading its source: a scope that narrows
178
+ # frames is one that will hide an unowned one.
179
+ def scope_filters_frames_by_owner?(parent)
180
+ scope = parent.allocate.send(:policy_scope, Janela::Frame)
181
+ scope.to_sql.include?("owner")
182
+ rescue StandardError
183
+ false
184
+ end
185
+
186
+ # Whether an endpoint is public depends on what the host's controller
187
+ # does, which cannot be determined by reading, so this observes and says
188
+ # so rather than declaring anything safe.
189
+ def unauthenticated_endpoints
190
+ parent = Janela.parent_controller.safe_constantize
191
+ return unless parent
192
+
193
+ filters = parent._process_action_callbacks.map(&:filter).map(&:to_s)
194
+ return if filters.any? { |filter| filter.match?(/authenticat|require_user|require_login|login_required/) }
195
+
196
+ Finding.new(severity: :warning,
197
+ summary: "no authentication filter found on #{parent}",
198
+ detail: " Janela's controllers inherit #{parent}, so they are as public as it is,\n" \
199
+ " and this check only reads its filters: if you authenticate another way\n" \
200
+ " this is a false alarm. Otherwise see Securing dashboards in the README.")
201
+ end
202
+
203
+ def janela_models
204
+ Rails.application.eager_load!
205
+ ActiveRecord::Base.descendants.select { |model| model.respond_to?(:janela) && model.janela }
206
+ rescue StandardError
207
+ []
208
+ end
209
+
210
+ def source_files
211
+ @source_files ||= SEARCHED.flat_map do |directory|
212
+ path = @root.join(directory)
213
+ next [] unless path.directory?
214
+
215
+ path.glob("**/*").select { |file| file.file? && READABLE.any? { |extension| file.to_s.end_with?(extension) } }
216
+ end
217
+ end
218
+ end
219
+ end
data/lib/janela/engine.rb CHANGED
@@ -8,10 +8,23 @@ module Janela
8
8
  ActiveSupport.on_load(:active_record) { extend Janela::Model }
9
9
  end
10
10
 
11
- # isolate_namespace keeps engine helpers out of the host, but the dashboard
11
+ # isolate_namespace keeps engine helpers out of the host, but the frame
12
12
  # helpers are the engine's public API and belong in the host's views.
13
13
  initializer "janela.helpers" do
14
- ActiveSupport.on_load(:action_view) { include Janela::DashboardHelper }
14
+ ActiveSupport.on_load(:action_view) { include Janela::FramesHelper }
15
+ end
16
+
17
+ # janela_frame renders the engine's own partials from a host's page, and
18
+ # an engine's views are otherwise only on the lookup path of its own
19
+ # controllers.
20
+ initializer "janela.views" do
21
+ ActiveSupport.on_load(:action_controller) { append_view_path Janela::Engine.root.join("app/views") }
22
+ end
23
+
24
+ # Janela's own layout links janela.css, and a host on Sprockets serves it
25
+ # in production only if something declared it.
26
+ initializer "janela.assets" do |app|
27
+ app.config.assets.precompile << "janela.css" if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:precompile)
15
28
  end
16
29
 
17
30
  initializer "janela.importmap", before: "importmap" do |app|
@@ -1,22 +1,37 @@
1
1
  module Janela
2
2
  class Measure
3
3
  AGGREGATES = %i[sum count average minimum maximum].freeze
4
+ # Aggregates whose answer is a number, so a boolean column would have its
5
+ # result cast back to true or false by ActiveRecord.
6
+ NUMERIC = %i[sum average].freeze
7
+ # Declared beside the aggregate, so they are taken out before the one
8
+ # remaining option is read as the aggregate.
9
+ FORMATS = %i[precision prefix suffix].freeze
10
+ # Where neither the aggregate nor the column says how many decimal places
11
+ # the measure means, two: enough for a rate or a currency, and short of
12
+ # the noise a float carries (issue #25).
13
+ FALLBACK_PRECISION = 2
4
14
 
5
- attr_reader :name, :aggregate, :column
15
+ attr_reader :name, :aggregate, :column, :prefix, :suffix
6
16
 
7
- def self.build(name, **options)
17
+ def self.build(name, model: nil, **options)
18
+ format = options.extract!(*FORMATS)
8
19
  aggregate, column = options.first
9
20
  unless options.size == 1 && AGGREGATES.include?(aggregate)
10
21
  raise Error, "measure #{name.inspect} needs exactly one of #{AGGREGATES.join(', ')}"
11
22
  end
12
23
 
13
- new(name, aggregate, column == true ? nil : column)
24
+ new(name, aggregate, column == true ? nil : column, model: model, **format)
14
25
  end
15
26
 
16
- def initialize(name, aggregate, column)
27
+ def initialize(name, aggregate, column, model: nil, precision: nil, prefix: nil, suffix: nil)
17
28
  @name = name
18
29
  @aggregate = aggregate
19
30
  @column = column
31
+ @model = model
32
+ @precision = precision!(precision)
33
+ @prefix = prefix
34
+ @suffix = suffix
20
35
  end
21
36
 
22
37
  def apply(relation)
@@ -28,5 +43,54 @@ module Janela
28
43
  def sql_alias
29
44
  "#{aggregate}_#{column || 'all'}"
30
45
  end
46
+
47
+ # How many decimal places this measure means. Counting rows has none, and
48
+ # a decimal column already declares its own scale, so money and counts
49
+ # read correctly with nothing declared at all (ADR 020).
50
+ def precision
51
+ @precision || column_scale || FALLBACK_PRECISION
52
+ end
53
+
54
+ # Rendering, never rounding: the number itself reaches a snapshot and a
55
+ # comparison at full precision, so a stored pane reads back under whatever
56
+ # format is declared later (ADR 009). Anything that is not a number is
57
+ # left alone, since minimum of a string is still that string.
58
+ def format(value)
59
+ return "" if value.nil?
60
+ return value.to_s unless value.is_a?(Numeric)
61
+
62
+ "#{prefix}#{rounded(value)}#{suffix}"
63
+ end
64
+
65
+ private
66
+ def rounded(value)
67
+ ActiveSupport::NumberHelper.number_to_rounded(value, precision: precision,
68
+ delimiter: I18n.t("number.format.delimiter", default: ","))
69
+ end
70
+
71
+ # Asking the schema rather than the value: one bucket of a measure can
72
+ # land on a whole number without the measure being a whole number.
73
+ def column_scale
74
+ return 0 if aggregate == :count || column.nil?
75
+
76
+ type = @model&.type_for_attribute(column)
77
+ return if type.nil?
78
+ return 0 if type.type == :integer && aggregate != :average
79
+
80
+ type.scale if type.respond_to?(:scale)
81
+ rescue ActiveRecord::ActiveRecordError
82
+ nil # no database to ask yet
83
+ end
84
+
85
+ def precision!(value)
86
+ return if value.nil?
87
+
88
+ places = Integer(value, exception: false)
89
+ unless places&.between?(0, 10)
90
+ raise Error, "measure #{name.inspect} takes a precision of 0 to 10 decimal places, not #{value.inspect}"
91
+ end
92
+
93
+ places
94
+ end
31
95
  end
32
96
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -9,14 +9,27 @@ require "janela/model"
9
9
  require "janela/definition"
10
10
  require "janela/measure"
11
11
  require "janela/dimension"
12
+ require "janela/doctor"
12
13
 
13
14
  module Janela
14
15
  class Error < StandardError; end
16
+ # Something the request named does not exist: a model, measure, dimension
17
+ # or a pane a snapshot did not freeze. Rendered as 404.
18
+ class NotFound < Error; end
19
+ # Something the request asked for is not allowed here: a renderer, a
20
+ # granularity, a limit or a filter. Rendered as 400.
21
+ class BadRequest < Error; end
15
22
 
16
23
  # Janela's controllers inherit from the host's, so the host's authentication
17
24
  # and authorisation apply to dashboards with no configuration.
18
25
  mattr_accessor :parent_controller, default: "ApplicationController"
19
26
 
27
+ # Checks janela:doctor should not report, by the name it prints beside each
28
+ # finding. A check that is a false alarm for one application stays a false
29
+ # alarm, and that is a judgement made once at boot, which is what a setting
30
+ # is for (ADR 021).
31
+ mattr_accessor :silenced_checks, default: []
32
+
20
33
  # Only models that declare a janela block are addressable over HTTP, keyed by
21
34
  # the route key that appears in pane URLs (orders, sales_orders). Names are
22
35
  # stored rather than classes so a reloaded model leaves nothing stale behind.
@@ -28,11 +41,21 @@ module Janela
28
41
  registry[model.model_name.route_key] = model.name
29
42
  end
30
43
 
44
+ # Every model that declares a janela block, for a form that offers a choice
45
+ # of them. Eager loading first, because a model nobody has referenced yet has
46
+ # not registered. A name that no longer resolves is left out rather than
47
+ # raised on: a model renamed or deleted in development leaves its old key
48
+ # here until a restart, and a form offering it would fail to draw at all.
49
+ def self.definitions
50
+ Rails.application.eager_load!
51
+ registry.sort.filter_map { |_route_key, class_name| class_name.safe_constantize&.janela }
52
+ end
53
+
31
54
  def self.definition!(route_key)
32
55
  # In development a model is only registered once autoloaded, so a cold
33
56
  # lookup loads the app rather than constantizing an unvetted parameter.
34
57
  Rails.application.eager_load! unless registry.key?(route_key)
35
- class_name = registry.fetch(route_key) { raise Error, "#{route_key.inspect} is not a janela model" }
58
+ class_name = registry.fetch(route_key) { raise NotFound, "#{route_key.inspect} is not a janela model" }
36
59
 
37
60
  class_name.constantize.janela
38
61
  end
@@ -0,0 +1,6 @@
1
+ namespace :janela do
2
+ desc "Report what this application still needs to do to use Janela correctly"
3
+ task doctor: :environment do
4
+ Janela::Doctor.new(Rails.root).report($stdout) or exit 1
5
+ end
6
+ end