janela 0.2.1 → 0.4.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/LICENSE.txt +1 -1
  4. data/README.md +244 -23
  5. data/UPGRADING.md +172 -0
  6. data/app/assets/javascripts/janela/chart_controller.js +23 -10
  7. data/app/assets/javascripts/janela/frame_controller.js +188 -0
  8. data/app/assets/javascripts/janela/vitral_controller.js +263 -0
  9. data/app/assets/stylesheets/janela.css +164 -0
  10. data/app/assets/stylesheets/vitral.css +343 -0
  11. data/app/controllers/janela/application_controller.rb +25 -0
  12. data/app/controllers/janela/frames_controller.rb +57 -0
  13. data/app/controllers/janela/panes_controller.rb +66 -13
  14. data/app/controllers/janela/queries_controller.rb +17 -0
  15. data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
  16. data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +26 -9
  17. data/app/models/janela/frame.rb +28 -0
  18. data/app/models/janela/pane.rb +102 -99
  19. data/app/models/janela/query.rb +162 -0
  20. data/app/models/janela/snapshot.rb +5 -5
  21. data/app/views/janela/frames/_card.html.erb +7 -0
  22. data/app/views/janela/frames/_form.html.erb +23 -0
  23. data/app/views/janela/frames/_frame.html.erb +5 -0
  24. data/app/views/janela/frames/_pane.html.erb +12 -0
  25. data/app/views/janela/frames/edit.html.erb +25 -0
  26. data/app/views/janela/frames/index.html.erb +15 -0
  27. data/app/views/janela/frames/new.html.erb +5 -0
  28. data/app/views/janela/frames/show.html.erb +9 -0
  29. data/app/views/janela/panes/_form.html.erb +58 -0
  30. data/app/views/janela/panes/_row.html.erb +12 -0
  31. data/app/views/janela/panes/edit.html.erb +5 -0
  32. data/app/views/janela/panes/new.html.erb +22 -0
  33. data/app/views/janela/panes/show.html.erb +3 -46
  34. data/app/views/janela/queries/_query.html.erb +47 -0
  35. data/app/views/janela/queries/show.html.erb +4 -0
  36. data/app/views/janela/shared/_errors.html.erb +7 -0
  37. data/app/views/layouts/janela/application.html.erb +22 -5
  38. data/config/importmap.rb +2 -1
  39. data/config/locales/en.yml +65 -0
  40. data/config/routes.rb +15 -2
  41. data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
  42. data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
  43. data/docs/decisions/001-built-to-be-forked.md +4 -0
  44. data/docs/decisions/009-snapshots.md +5 -2
  45. data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +8 -4
  46. data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
  47. data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
  48. data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
  49. data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
  50. data/docs/decisions/016-the-styling-vocabulary.md +100 -0
  51. data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
  52. data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
  53. data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
  54. data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
  55. data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
  56. data/docs/decisions/022-host-route-helpers-work-inside-the-engine.md +104 -0
  57. data/docs/decisions/023-vitral-is-a-theme-not-the-stylesheet.md +86 -0
  58. data/docs/decisions/024-selecting-more-than-one-value.md +127 -0
  59. data/docs/decisions/INDEX.md +25 -7
  60. data/docs/multi-tenancy.md +175 -0
  61. data/docs/naming.md +172 -0
  62. data/lib/janela/definition.rb +5 -5
  63. data/lib/janela/doctor.rb +219 -0
  64. data/lib/janela/engine.rb +24 -2
  65. data/lib/janela/host_routes.rb +31 -0
  66. data/lib/janela/measure.rb +65 -4
  67. data/lib/janela/version.rb +1 -1
  68. data/lib/janela.rb +24 -0
  69. data/lib/tasks/janela.rake +6 -0
  70. metadata +57 -4
  71. data/app/assets/javascripts/janela/dashboard_controller.js +0 -58
data/docs/naming.md ADDED
@@ -0,0 +1,172 @@
1
+ ---
2
+ Topics: naming, vocabulary, design, renaming
3
+ ---
4
+
5
+ # Naming Things Is Hard
6
+
7
+ There are two hard problems in computer science, and this page is about
8
+ the one that is not cache invalidation. Every name in Janela was chosen
9
+ on purpose, several were changed after they shipped, and one rule sits
10
+ under all of them.
11
+
12
+ ## The rule
13
+
14
+ > With words like Janela, Frame and Pane I am purposefully selecting
15
+ > uncommon but understandable, conceivable words so that people aren't
16
+ > pigeonholed and can select their own nomenclature.
17
+
18
+ A library that calls its main idea a Dashboard has taken that word from
19
+ every application that installs it. Your app probably already has a
20
+ `Dashboard`, or a `Report`, or an `Insight`, and whatever you call yours
21
+ is the word your users know. So Janela's own vocabulary is deliberately
22
+ a step to the side: close enough to understand on first read, unusual
23
+ enough that it never collides with the words you already use, and never
24
+ the word your users have to see.
25
+
26
+ That gives two tests for any name in this codebase:
27
+
28
+ - **Conceivable.** Someone reading it for the first time can guess what
29
+ it is without looking it up.
30
+ - **Uncommon.** It is unlikely to already be a model, a table, a route
31
+ or a word on your screens.
32
+
33
+ ## The window
34
+
35
+ *Janela* is Portuguese for window. The word is the same in Portugal and
36
+ in Brazil. Once the library is a window, the rest of its anatomy follows
37
+ from the thing itself rather than from a list of synonyms for "chart".
38
+
39
+ | Name | What it is | Why this word |
40
+ | --- | --- | --- |
41
+ | **Janela** | The library | A window onto your data. Uncommon in English, obvious once explained. |
42
+ | **Frame** | A dashboard: a name and a grid of panes | A window frame holds the panes. It is also what Turbo calls the element that makes cross-filtering work (ADR 003), which is a happy accident rather than the reason. |
43
+ | **Pane** | One visual inside a frame, stored as a row | A pane of glass sits in a frame. You look through each one at part of the picture. |
44
+ | **Query** | The runtime object that calculates one pane | Not a window word, on purpose. It is an implementation detail rather than something a person arranges, so it gets the plain name for what it does. |
45
+ | **Grid** | How a frame is divided: `columns`, `gap`, and each pane's `span` | A window is divided into panes, and the grid is the division. Small integers that choose a class the stylesheet already defines, so nothing an analyst types reaches CSS (ADR 016). |
46
+ | **Vitral** | The optional stained glass theme | A stained glass window, in the same language. See ADR 023. |
47
+
48
+ The anatomy was argued over before it was settled. *Sash* was
49
+ considered for the dashboard and rejected: a sash is one layer inside a
50
+ window, the moving part that holds glass, and the thing that holds
51
+ panes is the frame.
52
+
53
+ ## Words that stay ordinary
54
+
55
+ Not everything gets an unusual name, and the exceptions follow the same
56
+ reasoning.
57
+
58
+ **Measure and dimension** are the words the business intelligence field
59
+ already agreed on. An analyst who has used any tool of that kind knows
60
+ exactly what `measure :revenue` and `dimension :region` mean. They are
61
+ also method names inside a model's `janela` block rather than classes
62
+ sitting in your namespace, so there is nothing for them to collide with.
63
+ A domain language should speak its reader's language (ADR 002).
64
+
65
+ **Snapshot** and **owner** are plain because what they describe is
66
+ plain: the numbers frozen at an instant, and whatever a frame belongs
67
+ to. Janela assigns an owner and never reads it, so it has no reason to
68
+ give it a clever name (ADR 009, ADR 019).
69
+
70
+ **The doctor** is the conventional name for a command that reads your
71
+ setup and tells you what is wrong. Homebrew, Flutter, npm and Bundler
72
+ all ship one, so the word already means the right thing (ADR 021).
73
+
74
+ ## Your words, not ours
75
+
76
+ None of Janela's vocabulary has to reach your users. Two places decide
77
+ what they see.
78
+
79
+ **The noun comes from your locale file.** Every heading the engine
80
+ renders uses `Janela::Frame.model_name.human`, so your users read
81
+ whatever you call them:
82
+
83
+ ```yaml
84
+ en:
85
+ activerecord:
86
+ models:
87
+ janela/frame:
88
+ one: "Report"
89
+ other: "Reports"
90
+ ```
91
+
92
+ **The address is wherever you mount it.** Frames live at the mount root
93
+ and a pane's URL sits beneath the same path, so the words in the address
94
+ bar are yours too:
95
+
96
+ ```ruby
97
+ mount Janela::Engine => "/insights"
98
+ ```
99
+
100
+ ```
101
+ /insights every frame
102
+ /insights/3 one frame
103
+ /insights/orders/revenue a pane
104
+ ```
105
+
106
+ ADR 013 first gave frames a configurable path segment of their own. ADR
107
+ 014 took it back out, because a segment named `dashboards` or `reports`
108
+ is exactly the kind of word a host model already owns, and it shadowed
109
+ that model's panes. Keeping Janela's own words out of your URLs turned
110
+ out to be the fix as well as the principle.
111
+
112
+ ## One word, one meaning
113
+
114
+ A single name used for two things is worse than an awkward name, so
115
+ some sentences in this codebase are spelled more carefully than they
116
+ would be in conversation.
117
+
118
+ **Frame** on its own always means the dashboard. The HTML element is
119
+ always written **turbo frame**, in prose, in comments and in commit
120
+ messages, so the two can never be confused.
121
+
122
+ **Grid** always means a frame's layout, its columns and gap. The lines
123
+ drawn between panes by the theme are leading, never grid.
124
+
125
+ **Pane** always means the stored row. That is why the runtime object
126
+ had to give the name up: it was `Janela::Pane` until ADR 014 renamed it
127
+ `Janela::Query` so the record could take the word it deserved.
128
+
129
+ ## When a name turns out wrong
130
+
131
+ Names were changed after release, and each change was treated as
132
+ breaking rather than tidied away:
133
+
134
+ - `janela_dashboard` became `janela_frame`, and the Stimulus controller
135
+ `janela--dashboard` became `janela--frame`.
136
+ - `Janela::Pane` became `Janela::Query`, freeing `Pane` for the record.
137
+ - `Janela::DashboardHelper` became `Janela::FramesHelper`.
138
+
139
+ Every one of those is listed in `UPGRADING.md` with the exact
140
+ replacement, and `bin/rails janela:doctor` finds any old name left in
141
+ your code and names what replaced it (ADR 015). Renaming is allowed. A
142
+ rename a user has to discover for themselves is not.
143
+
144
+ ## The look follows the name
145
+
146
+ The demo and the vitral theme are not decoration picked separately.
147
+ Once the library is a window, the design had one obvious direction.
148
+
149
+ - **The panes are glass.** Each one holds its own colour, and the colour
150
+ cycles by position so a row is never monochrome.
151
+ - **The lines between them are leading,** dark and slightly uneven, the
152
+ way lead holds real stained glass. The theme calls its colour
153
+ `--vitral-came`, after the lead strip itself, but that is a styling
154
+ detail to override rather than a word you need to know.
155
+ - **The light comes from behind.** Shafts fall from a sun at the top
156
+ right, and hovering the mark fans light through the window, splitting
157
+ into its colours as it comes out the front.
158
+ - **The lattice leans toward you.** Its nodes reach for the cursor,
159
+ because a dashboard is meant to respond to the person looking at it.
160
+
161
+ ## Naming something new
162
+
163
+ If you are adding to Janela or forking it, the same checks apply:
164
+
165
+ 1. Would a stranger guess what it is from the name alone?
166
+ 2. Is it a word a Rails application is likely to have already?
167
+ 3. Does it already mean something else in this codebase, or in Rails,
168
+ or in Turbo?
169
+ 4. Is it something a person arranges, which earns a window word, or an
170
+ implementation detail, which gets a plain one?
171
+ 5. If you are renaming, have you added it to `UPGRADING.md` and taught
172
+ the doctor to find the old name?
@@ -9,7 +9,7 @@ module Janela
9
9
  end
10
10
 
11
11
  def measure(name, **aggregate)
12
- measures[name] = Measure.build(name, **aggregate).tap { |measure| reject_boolean_column!(measure) }
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)
@@ -44,6 +44,10 @@ module Janela
44
44
  dimensions.fetch(name) { raise NotFound, "#{model} has no janela dimension #{name.inspect}" }
45
45
  end
46
46
 
47
+ def measure!(name)
48
+ measures.fetch(name) { raise NotFound, "#{model} has no janela measure #{name.inspect}" }
49
+ end
50
+
47
51
  # ActiveRecord casts an aggregate back through the column's own type, so
48
52
  # AVG over a boolean returns true rather than a ratio. Say so at
49
53
  # declaration rather than rendering a meaningless pane.
@@ -91,9 +95,5 @@ module Janela
91
95
  raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
92
96
  "Declare a janela dimension, or add it to ransackable_attributes."
93
97
  end
94
-
95
- def measure!(name)
96
- measures.fetch(name) { raise NotFound, "#{model} has no janela measure #{name.inspect}" }
97
- end
98
98
  end
99
99
  end
@@ -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,32 @@ 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
+ if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:precompile)
28
+ app.config.assets.precompile << "janela.css"
29
+ app.config.assets.precompile << "vitral.css"
30
+ end
31
+ end
32
+
33
+ # A host's route names are only known once its routes are drawn, which is
34
+ # lazy and happens again on every reload in development.
35
+ initializer "janela.host_routes" do |app|
36
+ app.config.after_routes_loaded { Janela::HostRoutes.define! }
15
37
  end
16
38
 
17
39
  initializer "janela.importmap", before: "importmap" do |app|
@@ -0,0 +1,31 @@
1
+ module Janela
2
+ # Route helpers the host application has and the engine does not, forwarded
3
+ # to the host.
4
+ #
5
+ # isolate_namespace points every route helper inside Janela's controllers at
6
+ # the engine's own routes, and a host's ApplicationController runs there,
7
+ # because Janela's controllers inherit it. So an authentication redirect, a
8
+ # rescue_from or an after_action that names one of the host's own routes
9
+ # raises where it would work anywhere else in the application (ADR 022).
10
+ #
11
+ # Only names the engine does not define are forwarded, so the engine's own
12
+ # routes can never be shadowed by a host's. main_app stays the unambiguous
13
+ # way to say either.
14
+ module HostRoutes
15
+ def self.define!(host: Rails.application.routes, engine: Janela::Engine.routes)
16
+ # Routes reload in development, so a name that has gone is removed
17
+ # rather than left behind pointing at nothing.
18
+ instance_methods(false).each { |method| remove_method(method) }
19
+
20
+ forwarded(host: host, engine: engine).each do |name|
21
+ define_method(name) do |*args, **options, &block|
22
+ main_app.public_send(name, *args, **options, &block)
23
+ end
24
+ end
25
+ end
26
+
27
+ def self.forwarded(host: Rails.application.routes, engine: Janela::Engine.routes)
28
+ host.named_routes.helper_names - engine.named_routes.helper_names
29
+ end
30
+ end
31
+ end
@@ -4,22 +4,34 @@ module Janela
4
4
  # Aggregates whose answer is a number, so a boolean column would have its
5
5
  # result cast back to true or false by ActiveRecord.
6
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
7
14
 
8
- attr_reader :name, :aggregate, :column
15
+ attr_reader :name, :aggregate, :column, :prefix, :suffix
9
16
 
10
- def self.build(name, **options)
17
+ def self.build(name, model: nil, **options)
18
+ format = options.extract!(*FORMATS)
11
19
  aggregate, column = options.first
12
20
  unless options.size == 1 && AGGREGATES.include?(aggregate)
13
21
  raise Error, "measure #{name.inspect} needs exactly one of #{AGGREGATES.join(', ')}"
14
22
  end
15
23
 
16
- new(name, aggregate, column == true ? nil : column)
24
+ new(name, aggregate, column == true ? nil : column, model: model, **format)
17
25
  end
18
26
 
19
- def initialize(name, aggregate, column)
27
+ def initialize(name, aggregate, column, model: nil, precision: nil, prefix: nil, suffix: nil)
20
28
  @name = name
21
29
  @aggregate = aggregate
22
30
  @column = column
31
+ @model = model
32
+ @precision = precision!(precision)
33
+ @prefix = prefix
34
+ @suffix = suffix
23
35
  end
24
36
 
25
37
  def apply(relation)
@@ -31,5 +43,54 @@ module Janela
31
43
  def sql_alias
32
44
  "#{aggregate}_#{column || 'all'}"
33
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
34
95
  end
35
96
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.2.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -9,6 +9,8 @@ require "janela/model"
9
9
  require "janela/definition"
10
10
  require "janela/measure"
11
11
  require "janela/dimension"
12
+ require "janela/doctor"
13
+ require "janela/host_routes"
12
14
 
13
15
  module Janela
14
16
  class Error < StandardError; end
@@ -23,6 +25,18 @@ module Janela
23
25
  # and authorisation apply to dashboards with no configuration.
24
26
  mattr_accessor :parent_controller, default: "ApplicationController"
25
27
 
28
+ # The stylesheet Janela's own pages load on top of janela.css. Nil means the
29
+ # structural one only, which is what a host that has its own look wants. The
30
+ # gem ships "vitral". A host's own pages are untouched either way: they load
31
+ # whatever that host's layout says (ADR 023).
32
+ mattr_accessor :theme, default: nil
33
+
34
+ # Checks janela:doctor should not report, by the name it prints beside each
35
+ # finding. A check that is a false alarm for one application stays a false
36
+ # alarm, and that is a judgement made once at boot, which is what a setting
37
+ # is for (ADR 021).
38
+ mattr_accessor :silenced_checks, default: []
39
+
26
40
  # Only models that declare a janela block are addressable over HTTP, keyed by
27
41
  # the route key that appears in pane URLs (orders, sales_orders). Names are
28
42
  # stored rather than classes so a reloaded model leaves nothing stale behind.
@@ -34,6 +48,16 @@ module Janela
34
48
  registry[model.model_name.route_key] = model.name
35
49
  end
36
50
 
51
+ # Every model that declares a janela block, for a form that offers a choice
52
+ # of them. Eager loading first, because a model nobody has referenced yet has
53
+ # not registered. A name that no longer resolves is left out rather than
54
+ # raised on: a model renamed or deleted in development leaves its old key
55
+ # here until a restart, and a form offering it would fail to draw at all.
56
+ def self.definitions
57
+ Rails.application.eager_load!
58
+ registry.sort.filter_map { |_route_key, class_name| class_name.safe_constantize&.janela }
59
+ end
60
+
37
61
  def self.definition!(route_key)
38
62
  # In development a model is only registered once autoloaded, so a cold
39
63
  # lookup loads the app rather than constantizing an unvetted parameter.
@@ -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