janela 0.2.1 → 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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +200 -20
  4. data/UPGRADING.md +136 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +12 -5
  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 +18 -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 +102 -99
  16. data/app/models/janela/query.rb +149 -0
  17. data/app/models/janela/snapshot.rb +5 -5
  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 -46
  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 +13 -5
  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/009-snapshots.md +5 -2
  42. data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +8 -4
  43. data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
  44. data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
  45. data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
  46. data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
  47. data/docs/decisions/016-the-styling-vocabulary.md +100 -0
  48. data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
  49. data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
  50. data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
  51. data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
  52. data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
  53. data/docs/decisions/INDEX.md +22 -7
  54. data/docs/multi-tenancy.md +175 -0
  55. data/lib/janela/definition.rb +5 -5
  56. data/lib/janela/doctor.rb +219 -0
  57. data/lib/janela/engine.rb +15 -2
  58. data/lib/janela/measure.rb +65 -4
  59. data/lib/janela/version.rb +1 -1
  60. data/lib/janela.rb +17 -0
  61. data/lib/tasks/janela.rake +6 -0
  62. metadata +54 -4
@@ -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|
@@ -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.3.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -9,6 +9,7 @@ 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
@@ -23,6 +24,12 @@ module Janela
23
24
  # and authorisation apply to dashboards with no configuration.
24
25
  mattr_accessor :parent_controller, default: "ApplicationController"
25
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
+
26
33
  # Only models that declare a janela block are addressable over HTTP, keyed by
27
34
  # the route key that appears in pane URLs (orders, sales_orders). Names are
28
35
  # stored rather than classes so a reloaded model leaves nothing stale behind.
@@ -34,6 +41,16 @@ module Janela
34
41
  registry[model.model_name.route_key] = model.name
35
42
  end
36
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
+
37
54
  def self.definition!(route_key)
38
55
  # In development a model is only registered once autoloaded, so a cold
39
56
  # 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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: janela
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Killeen
@@ -95,21 +95,45 @@ files:
95
95
  - CHANGELOG.md
96
96
  - LICENSE.txt
97
97
  - README.md
98
+ - UPGRADING.md
98
99
  - app/assets/javascripts/janela/chart_controller.js
99
- - app/assets/javascripts/janela/dashboard_controller.js
100
+ - app/assets/javascripts/janela/frame_controller.js
100
101
  - app/assets/javascripts/janela/vendor/chart.js
102
+ - app/assets/stylesheets/janela.css
101
103
  - app/controllers/janela/application_controller.rb
104
+ - app/controllers/janela/frames_controller.rb
102
105
  - app/controllers/janela/panes_controller.rb
103
- - app/controllers/janela/snapshot_panes_controller.rb
104
- - app/helpers/janela/dashboard_helper.rb
106
+ - app/controllers/janela/queries_controller.rb
107
+ - app/controllers/janela/snapshot_queries_controller.rb
108
+ - app/helpers/janela/frames_helper.rb
105
109
  - app/jobs/janela/snapshot_job.rb
110
+ - app/models/janela/frame.rb
106
111
  - app/models/janela/pane.rb
112
+ - app/models/janela/query.rb
107
113
  - app/models/janela/snapshot.rb
114
+ - app/views/janela/frames/_card.html.erb
115
+ - app/views/janela/frames/_form.html.erb
116
+ - app/views/janela/frames/_frame.html.erb
117
+ - app/views/janela/frames/_pane.html.erb
118
+ - app/views/janela/frames/edit.html.erb
119
+ - app/views/janela/frames/index.html.erb
120
+ - app/views/janela/frames/new.html.erb
121
+ - app/views/janela/frames/show.html.erb
122
+ - app/views/janela/panes/_form.html.erb
123
+ - app/views/janela/panes/_row.html.erb
124
+ - app/views/janela/panes/edit.html.erb
125
+ - app/views/janela/panes/new.html.erb
108
126
  - app/views/janela/panes/show.html.erb
127
+ - app/views/janela/queries/_query.html.erb
128
+ - app/views/janela/queries/show.html.erb
129
+ - app/views/janela/shared/_errors.html.erb
109
130
  - app/views/layouts/janela/application.html.erb
110
131
  - config/importmap.rb
132
+ - config/locales/en.yml
111
133
  - config/routes.rb
112
134
  - db/migrate/20260915000001_create_janela_snapshots.rb
135
+ - db/migrate/20260916000001_create_janela_frames.rb
136
+ - db/migrate/20260916000002_create_janela_panes.rb
113
137
  - docs/decisions/001-built-to-be-forked.md
114
138
  - docs/decisions/002-measures-and-dimensions-over-ransack.md
115
139
  - docs/decisions/003-cross-filtering-with-turbo-frames.md
@@ -121,14 +145,27 @@ files:
121
145
  - docs/decisions/009-snapshots.md
122
146
  - docs/decisions/010-agent-guidance-ships-the-agent-waits.md
123
147
  - docs/decisions/011-panes-do-not-render-in-the-host-layout.md
148
+ - docs/decisions/012-frames-and-panes-are-data.md
149
+ - docs/decisions/013-naming-and-addressing-frames.md
150
+ - docs/decisions/014-corrections-before-frames-are-built.md
151
+ - docs/decisions/015-how-breaking-change-is-communicated.md
152
+ - docs/decisions/016-the-styling-vocabulary.md
153
+ - docs/decisions/017-janela-owns-no-data-store.md
154
+ - docs/decisions/018-a-table-is-the-universal-renderer.md
155
+ - docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md
156
+ - docs/decisions/020-formatting-belongs-to-the-measure.md
157
+ - docs/decisions/021-a-check-has-a-name-a-host-can-silence.md
124
158
  - docs/decisions/INDEX.md
159
+ - docs/multi-tenancy.md
125
160
  - lib/janela.rb
126
161
  - lib/janela/definition.rb
127
162
  - lib/janela/dimension.rb
163
+ - lib/janela/doctor.rb
128
164
  - lib/janela/engine.rb
129
165
  - lib/janela/measure.rb
130
166
  - lib/janela/model.rb
131
167
  - lib/janela/version.rb
168
+ - lib/tasks/janela.rake
132
169
  homepage: https://github.com/retail-tasker/janela
133
170
  licenses:
134
171
  - MIT
@@ -138,6 +175,19 @@ metadata:
138
175
  changelog_uri: https://github.com/retail-tasker/janela/blob/main/CHANGELOG.md
139
176
  bug_tracker_uri: https://github.com/retail-tasker/janela/issues
140
177
  rubygems_mfa_required: 'true'
178
+ post_install_message: |
179
+ Janela 0.3.0 needs two things from you. Run the migrations, because the
180
+ mount root now serves an index of frames:
181
+
182
+ bin/rails janela:install:migrations && bin/rails db:migrate
183
+
184
+ Then rename the Stimulus controller you register, the helper that wraps
185
+ your panes, and a few constants.
186
+
187
+ Steps: UPGRADING.md in this gem, or
188
+ https://github.com/retail-tasker/janela/blob/main/UPGRADING.md
189
+
190
+ Then run: bin/rails janela:doctor
141
191
  rdoc_options: []
142
192
  require_paths:
143
193
  - lib