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
@@ -0,0 +1,162 @@
1
+ module Janela
2
+ # The query behind one pane: a measure, optionally grouped by a dimension,
3
+ # rendered as a single value, a table or a chart. A query ignores filters on
4
+ # its own dimension so that clicking a value re-scopes the other panes
5
+ # rather than collapsing this one to the value clicked. A query read from a
6
+ # snapshot shows stored results and cannot be clicked at all.
7
+ class Query
8
+ RENDERERS = %w[table bar line].freeze
9
+
10
+ attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :snapshot
11
+
12
+ # The helper renders the turbo frame and the controller renders its
13
+ # replacement, so both derive the id the same way from the same parameters.
14
+ def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, snapshot: nil)
15
+ parts = [ "janela", ("snapshot_#{snapshot.id}" if snapshot), model.model_name.route_key, measure, by,
16
+ (granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit) ]
17
+ parts.compact.join("_")
18
+ end
19
+
20
+ def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, snapshot: nil, title: nil)
21
+ @definition = definition
22
+ @title = title
23
+ @measure = measure
24
+ @dimension = dimension
25
+ @renderer = renderer.to_s
26
+ @filters = filters
27
+ @snapshot = snapshot
28
+
29
+ raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
30
+ @granularity = Dimension.granularity!(granularity) if granularity.present?
31
+ @limit = definition.limit!(limit) if limit.present?
32
+ end
33
+
34
+ def model
35
+ definition.model
36
+ end
37
+
38
+ # Every number this pane renders goes through here, so a table cell, a
39
+ # single value and a chart tooltip cannot disagree about what the measure
40
+ # means (ADR 020).
41
+ def format(value)
42
+ definition.measure!(measure).format(value)
43
+ end
44
+
45
+ def single_value?
46
+ dimension.nil?
47
+ end
48
+
49
+ def time?
50
+ !single_value? && dimension_definition.time?
51
+ end
52
+
53
+ def frozen?
54
+ !snapshot.nil?
55
+ end
56
+
57
+ def granularity
58
+ @granularity || (dimension_definition.granularity if time?)
59
+ end
60
+
61
+ # Clicking a category adds one Ransack condition; clicking a time bucket
62
+ # would need two, and the dashboard toggles one key at a time (ADR 006).
63
+ # A stored pane is the record of a moment and is not clickable (ADR 009).
64
+ def clickable?
65
+ !single_value? && !time? && !frozen?
66
+ end
67
+
68
+ def chart?
69
+ !single_value? && renderer != "table"
70
+ end
71
+
72
+ def turbo_frame_id
73
+ self.class.turbo_frame_id(model: model, measure: measure, by: dimension, as: renderer,
74
+ granularity: @granularity, limit: limit, snapshot: snapshot)
75
+ end
76
+
77
+ # A pane row may carry its own title, which is the first analyst authored
78
+ # text the gem renders. It is escaped like any other string (ADR 014).
79
+ def title
80
+ return @title if @title.present?
81
+
82
+ base = if single_value?
83
+ measure.to_s.humanize
84
+ else
85
+ by = "#{measure.to_s.humanize} by #{dimension.to_s.humanize}"
86
+ time? ? "#{by} per #{granularity}" : by
87
+ end
88
+ frozen? ? "#{base} as of #{snapshot.taken_at.strftime('%-d %b %Y')}" : base
89
+ end
90
+
91
+ # What identifies this pane's data inside a snapshot.
92
+ def lookup_key
93
+ { "model" => model.model_name.route_key, "measure" => measure.to_s, "dimension" => dimension&.to_s,
94
+ "granularity" => granularity&.to_s, "limit" => limit }
95
+ end
96
+
97
+ def result(on: nil)
98
+ return snapshot.stored_result(self) if frozen?
99
+
100
+ definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
101
+ end
102
+
103
+ # The Ransack key and value a click on this label should toggle. A null
104
+ # group filters with the null predicate, not an empty string (ADR 009 has
105
+ # no say here; see issue #21).
106
+ # A click writes _in whether one value is selected or five, so there is one
107
+ # shape in the controller, the view and a stored snapshot. A hand written
108
+ # _eq link is still read, since a URL somebody already sent should not stop
109
+ # working to suit us (ADR 024).
110
+ def filter_params(label)
111
+ return [ nil, nil ] unless clickable?
112
+ return [ "#{ransack_name}_null", "1" ] if label.to_s == Dimension::NONE
113
+
114
+ [ "#{ransack_name}_in", label.to_s ]
115
+ end
116
+
117
+ # Every label in this pane paired with the filter it toggles, for a chart
118
+ # to look up by label when a bar is clicked.
119
+ def filters_for(labels)
120
+ return {} unless clickable?
121
+
122
+ labels.to_h { |label| [ label.to_s, filter_params(label) ] }
123
+ end
124
+
125
+ # The filter on this pane's own dimension is not applied to its query, but
126
+ # it is what the user clicked here, so the view highlights it.
127
+ # Every value of this pane's own dimension the filters name. A set, because
128
+ # a dimension can hold more than one (ADR 024), and the null group is in it
129
+ # like any other label.
130
+ def selected_values
131
+ return [] unless clickable?
132
+
133
+ values = Array(filter("#{ransack_name}_in")) + Array(filter("#{ransack_name}_eq"))
134
+ values = values.map(&:to_s)
135
+ values << Dimension::NONE if filter("#{ransack_name}_null").present?
136
+ values
137
+ end
138
+
139
+ def selected?(label)
140
+ selected_values.include?(label.to_s)
141
+ end
142
+
143
+ private
144
+ def filter(key)
145
+ filters[key] || filters[key.to_sym]
146
+ end
147
+
148
+ def dimension_definition
149
+ definition.dimension!(dimension)
150
+ end
151
+
152
+ def ransack_name
153
+ dimension_definition.ransack_name
154
+ end
155
+
156
+ def applicable_filters
157
+ return filters if single_value? || time?
158
+
159
+ filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
160
+ end
161
+ end
162
+ end
@@ -15,8 +15,8 @@ module Janela
15
15
  create!(name: name, taken_at: taken_at, filters: taking.filters, panes: taking.panes)
16
16
  end
17
17
 
18
- def stored_result(pane)
19
- key = pane.lookup_key
18
+ def stored_result(query)
19
+ key = query.lookup_key
20
20
  entry = panes.find { |stored| stored.slice(*key.keys) == key }
21
21
  raise NotFound, "snapshot #{id} has no pane #{key.compact.values.join(' ')}" unless entry
22
22
 
@@ -32,9 +32,9 @@ module Janela
32
32
  end
33
33
 
34
34
  def pane(model, measure, by: nil, granularity: nil, limit: nil, on: nil)
35
- pane = Pane.new(definition: model.janela, measure: measure, dimension: by,
36
- granularity: granularity, limit: limit, filters: filters)
37
- panes << pane.lookup_key.merge("result" => plain(pane.result(on: on)))
35
+ query = Query.new(definition: model.janela, measure: measure, dimension: by,
36
+ granularity: granularity, limit: limit, filters: filters)
37
+ panes << query.lookup_key.merge("result" => plain(query.result(on: on)))
38
38
  end
39
39
 
40
40
  private
@@ -0,0 +1,7 @@
1
+ <%= link_to frame_path(frame), class: "janela-card" do %>
2
+ <span class="janela-card-name"><%= frame.name %></span>
3
+ <span class="janela-card-meta">
4
+ <%= t("janela.frames.frame.panes", count: frame.panes.size) %>,
5
+ <%= t("janela.frames.frame.changed", time: time_ago_in_words(frame.updated_at)) %>
6
+ </span>
7
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <%= form_with model: frame, url: frame.persisted? ? frame_path(frame) : frames_path, class: "janela-form" do |form| %>
2
+ <%= render "janela/shared/errors", record: frame %>
3
+
4
+ <div class="janela-field">
5
+ <%= form.label :name %>
6
+ <%= form.text_field :name, required: true %>
7
+ </div>
8
+
9
+ <div class="janela-field">
10
+ <%= form.label :columns %>
11
+ <%= form.select :columns, Janela::Frame::COLUMNS.to_a %>
12
+ </div>
13
+
14
+ <div class="janela-field">
15
+ <%= form.label :gap %>
16
+ <%= form.select :gap, Janela::Frame::GAPS.to_a %>
17
+ </div>
18
+
19
+ <div class="janela-actions">
20
+ <%= form.submit t("janela.actions.save"), class: "janela-button" %>
21
+ <%= link_to t("janela.actions.cancel"), frame.persisted? ? frame_path(frame) : frames_path %>
22
+ </div>
23
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <%= tag.div class: janela_frame_classes(frame),
2
+ data: { controller: "janela--frame", action: "keydown.esc->janela--frame#clear",
3
+ janela__frame_filters_value: filters.to_json } do %>
4
+ <%= render partial: "janela/frames/pane", collection: frame.panes, as: :pane, locals: { filters: filters, charts: charts } %>
5
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <%# Rendered inline on the first response, so the page is a correct dashboard
2
+ before any JavaScript runs (ADR 014). Deliberately no src: a turbo frame
3
+ with both a src and content fetches the src and throws the content away.
4
+ The frame controller reads data-janela-src and sets src when filters
5
+ change, which is what makes cross-filtering work from here. %>
6
+ <%# A table needs no JavaScript, so it is what a surface with no chart runtime
7
+ shows in place of an empty canvas (ADR 018). %>
8
+ <% query = pane.query(filters: filters, renderer: pane.chart? && !charts ? "table" : pane.renderer) %>
9
+ <%= turbo_frame_tag pane.turbo_frame_id, class: "janela-span-#{pane.span}",
10
+ data: { janela__frame_target: "pane", janela_src: janela_routes.frame_pane_path(pane.frame_id, pane) } do %>
11
+ <%= render "janela/queries/query", query: query, result: query.result(on: janela_scope(query.model)) %>
12
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <% content_for :title, @frame.name %>
2
+ <h1 class="janela-heading"><%= @frame.name %></h1>
3
+ <p class="janela-crumb">
4
+ <%= link_to Janela::Frame.model_name.human(count: 2), frames_path %>
5
+ <%= link_to t("janela.actions.view"), frame_path(@frame) %>
6
+ </p>
7
+
8
+ <%= render "form", frame: @frame %>
9
+
10
+ <h2 class="janela-subheading"><%= Janela::Pane.model_name.human(count: 2) %></h2>
11
+
12
+ <% if @frame.panes.any? %>
13
+ <ul class="janela-list">
14
+ <%= render partial: "janela/panes/row", collection: @frame.panes, as: :pane, locals: { frame: @frame } %>
15
+ </ul>
16
+ <% else %>
17
+ <p class="janela-empty"><%= t(".blank", name: Janela::Pane.model_name.human(count: 2).downcase) %></p>
18
+ <% end %>
19
+
20
+ <p class="janela-actions">
21
+ <%= link_to t("janela.actions.add_pane", name: Janela::Pane.model_name.human), new_frame_pane_path(@frame), class: "janela-button" %>
22
+ </p>
23
+
24
+ <%= button_to t("janela.actions.delete_frame", name: Janela::Frame.model_name.human.downcase), frame_path(@frame),
25
+ method: :delete, class: "janela-button janela-danger" %>
@@ -0,0 +1,15 @@
1
+ <% content_for :title, Janela::Frame.model_name.human(count: 2) %>
2
+ <h1 class="janela-heading"><%= Janela::Frame.model_name.human(count: 2) %></h1>
3
+ <p class="janela-actions">
4
+ <%= link_to t("janela.actions.new_frame", name: Janela::Frame.model_name.human), new_frame_path, class: "janela-button" %>
5
+ </p>
6
+
7
+ <% if @frames.any? %>
8
+ <div class="janela-frame janela-cols-3 janela-gap-4">
9
+ <% @frames.each do |frame| %>
10
+ <%= render "janela/frames/card", frame: frame %>
11
+ <% end %>
12
+ </div>
13
+ <% else %>
14
+ <p class="janela-empty"><%= t(".blank", name: Janela::Frame.model_name.human(count: 2).downcase) %></p>
15
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <% content_for :title, t("janela.actions.new_frame", name: Janela::Frame.model_name.human) %>
2
+ <h1 class="janela-heading"><%= t("janela.actions.new_frame", name: Janela::Frame.model_name.human) %></h1>
3
+ <p class="janela-crumb"><%= link_to Janela::Frame.model_name.human(count: 2), frames_path %></p>
4
+
5
+ <%= render "form", frame: @frame %>
@@ -0,0 +1,9 @@
1
+ <% content_for :title, @frame.name %>
2
+ <h1 class="janela-heading"><%= @frame.name %></h1>
3
+ <p class="janela-crumb">
4
+ <%= link_to Janela::Frame.model_name.human(count: 2), frames_path %>
5
+ <%= link_to t("janela.actions.edit"), edit_frame_path(@frame) %>
6
+ </p>
7
+
8
+ <%# charts: false because this page loads no chart runtime (ADR 018). %>
9
+ <%= janela_frame @frame, charts: false %>
@@ -0,0 +1,58 @@
1
+ <%
2
+ # Step two: every option comes from this model's janela block, so a choice
3
+ # that would be rejected is not offered in the first place.
4
+ measures = definition.measures.keys.map { |name| [ name.to_s.humanize, name ] }
5
+ dimensions = definition.dimensions.keys.map { |name| [ name.to_s.humanize, name ] }
6
+ time_dimensions = definition.dimensions.values.select(&:time?).map { |dimension| dimension.name.to_s.humanize }
7
+ chosen = definition.dimensions[pane.dimension.presence&.to_sym]
8
+ granularity_applies = pane.dimension.present? ? chosen&.time? : time_dimensions.any?
9
+ %>
10
+ <%= form_with model: pane, url: pane.persisted? ? frame_pane_path(frame, pane) : frame_panes_path(frame), class: "janela-form" do |form| %>
11
+ <%= render "janela/shared/errors", record: pane %>
12
+ <%= form.hidden_field :model %>
13
+
14
+ <p class="janela-muted"><%= Janela::Pane.human_attribute_name(:model) %>: <%= definition.model.model_name.human %></p>
15
+
16
+ <div class="janela-field">
17
+ <%= form.label :measure %>
18
+ <%= form.select :measure, measures %>
19
+ </div>
20
+
21
+ <div class="janela-field">
22
+ <%= form.label :dimension %>
23
+ <%= form.select :dimension, dimensions, include_blank: t("janela.panes.no_dimension") %>
24
+ </div>
25
+
26
+ <div class="janela-field">
27
+ <%= form.label :renderer %>
28
+ <%= form.select :renderer, Janela::Query::RENDERERS.map { |renderer| [ t("janela.renderers.#{renderer}"), renderer ] } %>
29
+ </div>
30
+
31
+ <% if granularity_applies %>
32
+ <div class="janela-field">
33
+ <%= form.label :granularity %>
34
+ <%= form.select :granularity, Janela::Dimension::GRANULARITIES.map { |value| [ value.humanize, value ] }, include_blank: t("janela.panes.declared_granularity") %>
35
+ <span class="janela-hint"><%= t("janela.panes.granularity_hint", dimensions: time_dimensions.to_sentence) %></span>
36
+ </div>
37
+ <% end %>
38
+
39
+ <div class="janela-field">
40
+ <%= form.label :limit %>
41
+ <%= form.select :limit, Janela::Pane::OFFERED_LIMITS, include_blank: t("janela.panes.no_limit") %>
42
+ </div>
43
+
44
+ <div class="janela-field">
45
+ <%= form.label :span %>
46
+ <%= form.select :span, Janela::Pane::SPANS.to_a %>
47
+ </div>
48
+
49
+ <div class="janela-field">
50
+ <%= form.label :title %>
51
+ <%= form.text_field :title, placeholder: t("janela.panes.title_placeholder") %>
52
+ </div>
53
+
54
+ <div class="janela-actions">
55
+ <%= form.submit t("janela.actions.save"), class: "janela-button" %>
56
+ <%= link_to t("janela.actions.cancel"), edit_frame_path(frame) %>
57
+ </div>
58
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <li class="janela-list-row">
2
+ <span class="janela-list-name"><%= pane.label %></span>
3
+ <span class="janela-card-meta"><%= t("janela.panes.summary", renderer: pane.renderer, span: pane.span) %></span>
4
+ <span class="janela-actions">
5
+ <%= button_to t("janela.actions.move_up"), move_up_frame_pane_path(frame, pane), method: :patch,
6
+ class: "janela-button", disabled: pane == frame.panes.first %>
7
+ <%= button_to t("janela.actions.move_down"), move_down_frame_pane_path(frame, pane), method: :patch,
8
+ class: "janela-button", disabled: pane == frame.panes.last %>
9
+ <%= link_to t("janela.actions.edit"), edit_frame_pane_path(frame, pane) %>
10
+ <%= button_to t("janela.actions.delete"), frame_pane_path(frame, pane), method: :delete, class: "janela-button" %>
11
+ </span>
12
+ </li>
@@ -0,0 +1,5 @@
1
+ <% content_for :title, @pane.label %>
2
+ <h1 class="janela-heading"><%= @pane.label %></h1>
3
+ <p class="janela-crumb"><%= link_to @frame.name, edit_frame_path(@frame) %></p>
4
+
5
+ <%= render "form", frame: @frame, pane: @pane, definition: @definition %>
@@ -0,0 +1,22 @@
1
+ <% content_for :title, t("janela.actions.add_pane", name: Janela::Pane.model_name.human) %>
2
+ <h1 class="janela-heading"><%= t("janela.actions.add_pane", name: Janela::Pane.model_name.human) %></h1>
3
+ <p class="janela-crumb"><%= link_to @frame.name, edit_frame_path(@frame) %></p>
4
+
5
+ <% if @definition %>
6
+ <%= render "form", frame: @frame, pane: @pane, definition: @definition %>
7
+ <% else %>
8
+ <%# Step one of two. The engine's pages run no JavaScript, so one select
9
+ cannot refill another: picking the model first is what lets step two
10
+ offer only measures and dimensions that model declares (ADR 018). %>
11
+ <%= form_with url: new_frame_pane_path(@frame), method: :get, class: "janela-form" do |form| %>
12
+ <div class="janela-field">
13
+ <%= form.label :model, Janela::Pane.human_attribute_name(:model) %>
14
+ <%= form.select :model, Janela.definitions.map { |definition| [ definition.model.model_name.human, definition.model.model_name.route_key ] } %>
15
+ </div>
16
+
17
+ <div class="janela-actions">
18
+ <%= form.submit t("janela.actions.continue"), class: "janela-button" %>
19
+ <%= link_to t("janela.actions.cancel"), edit_frame_path(@frame) %>
20
+ </div>
21
+ <% end %>
22
+ <% end %>
@@ -1,47 +1,4 @@
1
- <% content_for :title, @pane.title %>
2
- <%= turbo_frame_tag @pane.frame_id do %>
3
- <% if @pane.single_value? %>
4
- <p class="janela-pane janela-value">
5
- <span class="janela-value-label"><%= @pane.title %></span>
6
- <strong class="janela-value-number"><%= number_with_delimiter(@result || 0) %></strong>
7
- </p>
8
- <% elsif @result.empty? %>
9
- <p class="janela-pane janela-empty"><%= @pane.title %>: no data</p>
10
- <% elsif @pane.chart? %>
11
- <canvas class="janela-pane janela-chart"
12
- data-controller="janela--chart"
13
- data-action="janela--chart:toggle->janela--dashboard#toggle"
14
- data-janela--chart-type-value="<%= @pane.renderer %>"
15
- data-janela--chart-title-value="<%= @pane.title %>"
16
- data-janela--chart-selected-value="<%= @pane.selected_value %>"
17
- data-janela--chart-labels-value="<%= @result.keys.to_json %>"
18
- data-janela--chart-values-value="<%= @result.values.map(&:to_f).to_json %>"
19
- data-janela--chart-filters-value="<%= @pane.filters_for(@result.keys).to_json %>"
20
- role="img" aria-label="<%= @pane.title %>"></canvas>
21
- <% else %>
22
- <table class="janela-pane">
23
- <caption><%= @pane.title %></caption>
24
- <tbody>
25
- <% @result.each do |label, measured| %>
26
- <% key, value = @pane.filter_params(label) %>
27
- <tr>
28
- <td>
29
- <% if key %>
30
- <button type="button"
31
- aria-pressed="<%= label.to_s == @pane.selected_value.to_s && @pane.selected_value.present? %>"
32
- data-action="janela--dashboard#toggle"
33
- data-janela--dashboard-key-param="<%= key %>"
34
- data-janela--dashboard-value-param="<%= value %>">
35
- <%= label %>
36
- </button>
37
- <% else %>
38
- <span><%= label %></span>
39
- <% end %>
40
- </td>
41
- <td><%= number_with_delimiter(measured) %></td>
42
- </tr>
43
- <% end %>
44
- </tbody>
45
- </table>
46
- <% end %>
1
+ <% content_for :title, @query.title %>
2
+ <%= turbo_frame_tag @pane.turbo_frame_id do %>
3
+ <%= render "janela/queries/query", query: @query, result: @result %>
47
4
  <% end %>
@@ -0,0 +1,47 @@
1
+ <%# One pane's content, without its turbo frame: the same markup whether the
2
+ pane came from a URL, a row, or a frame rendered inline. %>
3
+ <% if query.single_value? %>
4
+ <p class="janela-pane janela-value">
5
+ <span class="janela-value-label"><%= query.title %></span>
6
+ <strong class="janela-value-number"><%= query.format(result || 0) %></strong>
7
+ </p>
8
+ <% elsif result.empty? %>
9
+ <p class="janela-pane janela-empty"><%= query.title %>: no data</p>
10
+ <% elsif query.chart? %>
11
+ <canvas class="janela-pane janela-chart"
12
+ data-controller="janela--chart"
13
+ data-action="janela--chart:toggle->janela--frame#toggle"
14
+ data-janela--chart-type-value="<%= query.renderer %>"
15
+ data-janela--chart-title-value="<%= query.title %>"
16
+ data-janela--chart-selected-value="<%= query.selected_values.to_json %>"
17
+ data-janela--chart-labels-value="<%= result.keys.to_json %>"
18
+ data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
19
+ data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
20
+ data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
21
+ role="img" aria-label="<%= query.title %>"></canvas>
22
+ <% else %>
23
+ <table class="janela-pane">
24
+ <caption><%= query.title %></caption>
25
+ <tbody>
26
+ <% result.each do |label, measured| %>
27
+ <% key, value = query.filter_params(label) %>
28
+ <tr>
29
+ <td>
30
+ <% if key %>
31
+ <button type="button"
32
+ aria-pressed="<%= query.selected?(label) %>"
33
+ data-action="janela--frame#toggle"
34
+ data-janela--frame-key-param="<%= key %>"
35
+ data-janela--frame-value-param="<%= value %>">
36
+ <%= label %>
37
+ </button>
38
+ <% else %>
39
+ <span><%= label %></span>
40
+ <% end %>
41
+ </td>
42
+ <td><%= query.format(measured) %></td>
43
+ </tr>
44
+ <% end %>
45
+ </tbody>
46
+ </table>
47
+ <% end %>
@@ -0,0 +1,4 @@
1
+ <% content_for :title, @query.title %>
2
+ <%= turbo_frame_tag @query.turbo_frame_id do %>
3
+ <%= render "janela/queries/query", query: @query, result: @result %>
4
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <% if record.errors.any? %>
2
+ <ul class="janela-errors">
3
+ <% record.errors.full_messages.each do |message| %>
4
+ <li><%= message %></li>
5
+ <% end %>
6
+ </ul>
7
+ <% end %>
@@ -1,7 +1,11 @@
1
- <%# A pane opened directly, outside a Turbo Frame. Deliberately minimal: the
2
- engine cannot know the host's asset names or bundler, and a host layout's
3
- route helpers do not resolve inside an isolated engine (ADR 011). A host
4
- can override this file, or point the controller at its own layout. %>
1
+ <%# Janela's own pages and a pane opened directly, outside a Turbo Frame.
2
+ Deliberately minimal: the engine cannot know the host's asset names or
3
+ bundler, and a host layout's route helpers do not resolve inside an
4
+ isolated engine (ADR 011). It loads Janela's own stylesheet, which the gem
5
+ ships and can therefore name, plus a theme if one is set, and no JavaScript: Turbo and Stimulus come
6
+ from the host, so these pages are static dashboards rather than broken
7
+ ones. A host can override this file, or point the controller at its own
8
+ layout. %>
5
9
  <!DOCTYPE html>
6
10
  <html>
7
11
  <head>
@@ -10,9 +14,22 @@
10
14
  <meta name="viewport" content="width=device-width,initial-scale=1">
11
15
  <%= csrf_meta_tags %>
12
16
  <%= csp_meta_tag %>
17
+ <%= stylesheet_link_tag "janela" %>
18
+ <%= stylesheet_link_tag Janela.theme if Janela.theme.present? %>
13
19
  <%= yield :head %>
14
20
  </head>
15
- <body>
21
+ <body class="janela-page">
22
+ <%# Janela's own pages are reached from somewhere, and until ADR 022 the
23
+ engine could not name a host route to get back to. It can now, so the
24
+ layout stops being a dead end. Only when the host has a root, and
25
+ never a guess at what to call the place it goes. %>
26
+ <% if main_app.respond_to?(:root_path) %>
27
+ <p class="janela-exit"><%= link_to t("janela.actions.home"), main_app.root_path %></p>
28
+ <% end %>
29
+
30
+ <% flash.each do |_kind, message| %>
31
+ <p class="janela-flash"><%= message %></p>
32
+ <% end %>
16
33
  <%= yield %>
17
34
  </body>
18
35
  </html>
data/config/importmap.rb CHANGED
@@ -1,5 +1,6 @@
1
- pin "janela/dashboard_controller", to: "janela/dashboard_controller.js"
1
+ pin "janela/frame_controller", to: "janela/frame_controller.js"
2
2
  pin "janela/chart_controller", to: "janela/chart_controller.js"
3
+ pin "janela/vitral_controller", to: "janela/vitral_controller.js"
3
4
 
4
5
  # Hosts that already pin their own Chart.js keep it.
5
6
  pin "chart.js", to: "janela/vendor/chart.js" unless packages.key?("chart.js")
@@ -0,0 +1,65 @@
1
+ en:
2
+ activerecord:
3
+ models:
4
+ janela/frame:
5
+ one: Frame
6
+ other: Frames
7
+ janela/pane:
8
+ one: Pane
9
+ other: Panes
10
+ attributes:
11
+ janela/frame:
12
+ name: Name
13
+ columns: Columns
14
+ gap: Gap
15
+ janela/pane:
16
+ model: Model
17
+ measure: Measure
18
+ dimension: Dimension
19
+ renderer: Renderer
20
+ granularity: Granularity
21
+ limit: Rows
22
+ span: Width
23
+ title: Title
24
+ janela:
25
+ actions:
26
+ add_pane: "Add a %{name}"
27
+ cancel: Cancel
28
+ continue: Continue
29
+ delete: Remove
30
+ delete_frame: "Delete this %{name}"
31
+ edit: Edit
32
+ home: "Back to the site"
33
+ move_down: Down
34
+ move_up: Up
35
+ new_frame: "New %{name}"
36
+ save: Save
37
+ view: View
38
+ frames:
39
+ created: Created.
40
+ destroyed: Deleted.
41
+ updated: Saved.
42
+ index:
43
+ blank: "No %{name} yet."
44
+ edit:
45
+ blank: "No %{name} yet."
46
+ frame:
47
+ panes:
48
+ zero: no panes
49
+ one: 1 pane
50
+ other: "%{count} panes"
51
+ changed: "changed %{time} ago"
52
+ panes:
53
+ created: Added.
54
+ destroyed: Removed.
55
+ updated: Saved.
56
+ declared_granularity: As the model declares it
57
+ granularity_hint: "Applies to %{dimensions} only."
58
+ no_dimension: "No breakdown, one number"
59
+ no_limit: Every row
60
+ summary: "%{renderer}, %{span} wide"
61
+ title_placeholder: Janela writes one if you leave this blank
62
+ renderers:
63
+ bar: Bar chart
64
+ line: Line chart
65
+ table: Table
data/config/routes.rb CHANGED
@@ -1,9 +1,22 @@
1
1
  Janela::Engine.routes.draw do
2
2
  segment = /[a-z0-9_]+/
3
3
 
4
- get "snapshots/:snapshot_id/:model/:measure(/:dimension)", to: "snapshot_panes#show", as: :snapshot_pane,
4
+ # Frames sit at the mount root and are drawn before the pane grammar, with a
5
+ # numeric constraint: no model's route key is all digits, so /3 is a frame
6
+ # and /orders/revenue is a pane, with nothing to disambiguate (ADR 014).
7
+ resources :frames, path: "", constraints: { id: /\d+/ } do
8
+ # No index: a frame's own edit page is the list of its panes.
9
+ resources :panes, except: :index, constraints: { id: /\d+/, frame_id: /\d+/ } do
10
+ member do
11
+ patch :move_up
12
+ patch :move_down
13
+ end
14
+ end
15
+ end
16
+
17
+ get "snapshots/:snapshot_id/:model/:measure(/:dimension)", to: "snapshot_queries#show", as: :snapshot_pane,
5
18
  constraints: { snapshot_id: /\d+/, model: segment, measure: segment, dimension: segment }
6
19
 
7
- get ":model/:measure(/:dimension)", to: "panes#show", as: :pane,
20
+ get ":model/:measure(/:dimension)", to: "queries#show", as: :pane,
8
21
  constraints: { model: segment, measure: segment, dimension: segment }
9
22
  end