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
@@ -1,137 +1,140 @@
1
1
  module Janela
2
- # One pane of a dashboard: a measure, optionally grouped by a dimension,
3
- # rendered as a single value, a table or a chart. A pane 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 pane read from a
6
- # snapshot shows stored results and cannot be clicked at all.
7
- class Pane
8
- RENDERERS = %w[table bar line].freeze
9
-
10
- attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :snapshot
11
-
12
- # The helper renders the frame and the controller renders its replacement,
13
- # so both derive the id the same way from the same parameters.
14
- def self.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
2
+ # One pane of a frame, as data: where it sits, how wide it is, and what it
3
+ # shows (ADR 012). A row may only name a measure and a dimension the model's
4
+ # janela block declares, so an analyst arranges what is shown and cannot
5
+ # invent a query or reach a model nobody exposed.
6
+ class Pane < ActiveRecord::Base
7
+ SPANS = (1..12).freeze
8
+ LIMITS = (1..1000).freeze
9
+ # A form cannot offer a thousand options, and these are the row counts a
10
+ # dashboard actually asks for. Any limit inside LIMITS is still valid.
11
+ OFFERED_LIMITS = [ 5, 10, 20, 50, 100 ].freeze
19
12
 
20
- def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, snapshot: nil)
21
- @definition = definition
22
- @measure = measure
23
- @dimension = dimension
24
- @renderer = renderer.to_s
25
- @filters = filters
26
- @snapshot = snapshot
27
-
28
- raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
29
- @granularity = Dimension.granularity!(granularity) if granularity.present?
30
- @limit = definition.limit!(limit) if limit.present?
31
- end
13
+ belongs_to :frame
32
14
 
33
- def model
34
- definition.model
35
- end
15
+ before_validation :assign_position, on: :create
36
16
 
37
- def single_value?
38
- dimension.nil?
17
+ validates :position, presence: true
18
+ validates :measure, presence: true
19
+ validates :span, inclusion: { in: SPANS }
20
+ validates :limit, inclusion: { in: LIMITS }, allow_nil: true
21
+ validate :declared_by_a_janela_block
22
+
23
+ # The DOM id is the row, not the query it runs: two rows in one frame may
24
+ # show the same measure by the same dimension, and a fingerprint of the
25
+ # query would give them the same turbo frame for Turbo to replace (ADR 014).
26
+ def turbo_frame_id
27
+ "janela_pane_#{id}"
39
28
  end
40
29
 
41
- def time?
42
- !single_value? && dimension_definition.time?
30
+ # renderer: overrides what the row asked for, because a renderer is a
31
+ # viewing choice and a surface without a chart runtime shows a table
32
+ # instead (ADR 018).
33
+ def query(filters: {}, renderer: self.renderer)
34
+ Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
35
+ renderer: renderer, granularity: granularity, limit: limit, filters: filters, title: title)
43
36
  end
44
37
 
45
- def frozen?
46
- !snapshot.nil?
38
+ # The row's own words, for a list or a heading. Built from the columns
39
+ # rather than from a query, because an editing page has to render even if
40
+ # a janela block has since lost the dimension this row names.
41
+ def label
42
+ return title if title.present?
43
+
44
+ dimension.present? ? "#{measure.humanize} by #{dimension.humanize}" : measure.to_s.humanize
47
45
  end
48
46
 
49
- def granularity
50
- @granularity || (dimension_definition.granularity if time?)
47
+ # Arranging the window is the point of the editing surface, and a number
48
+ # field is not arranging, so a pane swaps places with its neighbour.
49
+ # Written without validations: the swap passes through a moment where two
50
+ # panes share a position, and neither row's own data is being changed.
51
+ def move_up
52
+ swap_with(panes_above.last)
51
53
  end
52
54
 
53
- # Clicking a category adds one Ransack condition; clicking a time bucket
54
- # would need two, and the dashboard toggles one key at a time (ADR 006).
55
- # A stored pane is the record of a moment and is not clickable (ADR 009).
56
- def clickable?
57
- !single_value? && !time? && !frozen?
55
+ def move_down
56
+ swap_with(panes_below.first)
58
57
  end
59
58
 
60
59
  def chart?
61
- !single_value? && renderer != "table"
60
+ Query::RENDERERS.include?(renderer.to_s) && renderer.to_s != "table"
62
61
  end
63
62
 
64
- def frame_id
65
- self.class.frame_id(model: model, measure: measure, by: dimension, as: renderer,
66
- granularity: @granularity, limit: limit, snapshot: snapshot)
63
+ def definition
64
+ Janela.definition!(model)
67
65
  end
68
66
 
69
- def title
70
- base = if single_value?
71
- measure.to_s.humanize
72
- else
73
- by = "#{measure.to_s.humanize} by #{dimension.to_s.humanize}"
74
- time? ? "#{by} per #{granularity}" : by
67
+ private
68
+ def panes_above
69
+ frame.panes.where(position: ...position).order(:position)
75
70
  end
76
- frozen? ? "#{base} as of #{snapshot.taken_at.strftime('%-d %b %Y')}" : base
77
- end
78
-
79
- # What identifies this pane's data inside a snapshot.
80
- def lookup_key
81
- { "model" => model.model_name.route_key, "measure" => measure.to_s, "dimension" => dimension&.to_s,
82
- "granularity" => granularity&.to_s, "limit" => limit }
83
- end
84
71
 
85
- def result(on: nil)
86
- return snapshot.stored_result(self) if frozen?
72
+ def panes_below
73
+ frame.panes.where(position: (position + 1)..).order(:position)
74
+ end
87
75
 
88
- definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
89
- end
76
+ def swap_with(other)
77
+ return false if other.nil?
90
78
 
91
- # The Ransack key and value a click on this label should toggle. A null
92
- # group filters with the null predicate, not an empty string (ADR 009 has
93
- # no say here; see issue #21).
94
- def filter_params(label)
95
- return [ nil, nil ] unless clickable?
96
- return [ "#{ransack_name}_null", "1" ] if label.to_s == Dimension::NONE
79
+ transaction do
80
+ mine = position
81
+ update_columns(position: other.position, updated_at: Time.current)
82
+ other.update_columns(position: mine, updated_at: Time.current)
83
+ frame.touch
84
+ end
85
+ true
86
+ end
97
87
 
98
- [ "#{ransack_name}_eq", label.to_s ]
99
- end
88
+ def assign_position
89
+ self.position ||= (frame&.panes&.maximum(:position) || 0) + 1
90
+ end
100
91
 
101
- # Every label in this pane paired with the filter it toggles, for a chart
102
- # to look up by label when a bar is clicked.
103
- def filters_for(labels)
104
- return {} unless clickable?
92
+ # A janela block can lose a measure or a dimension long after a row named
93
+ # it, and the pane would then fail at render as a missing pane rather
94
+ # than a bad row. Checked on save, and as errors rather than raising, so
95
+ # the row is rejected with something a person can read (ADR 014).
96
+ def declared_by_a_janela_block
97
+ return errors.add(:model, "must be a model with a janela block") if model.blank?
98
+
99
+ declaration = definition
100
+ validate_measure(declaration) if measure.present?
101
+ dimension_declaration = validate_dimension(declaration)
102
+ validate_renderer
103
+ validate_granularity(dimension_declaration)
104
+ rescue Janela::Error
105
+ errors.add(:model, "#{model.inspect} is not a janela model")
106
+ end
105
107
 
106
- labels.to_h { |label| [ label.to_s, filter_params(label) ] }
107
- end
108
+ def validate_measure(declaration)
109
+ return if declaration.measures.key?(measure.to_s.to_sym)
108
110
 
109
- # The filter on this pane's own dimension is not applied to its query, but
110
- # it is what the user clicked here, so the view highlights it.
111
- def selected_value
112
- return unless clickable?
113
- return Dimension::NONE if filter("#{ransack_name}_null").present?
111
+ errors.add(:measure, "#{measure.inspect} is not a measure of #{declaration.model}")
112
+ end
114
113
 
115
- filter("#{ransack_name}_eq")
116
- end
114
+ def validate_dimension(declaration)
115
+ return if dimension.blank?
117
116
 
118
- private
119
- def filter(key)
120
- filters[key] || filters[key.to_sym]
117
+ declaration.dimensions.fetch(dimension.to_sym) do
118
+ errors.add(:dimension, "#{dimension.inspect} is not a dimension of #{declaration.model}")
119
+ nil
120
+ end
121
121
  end
122
122
 
123
- def dimension_definition
124
- definition.dimension!(dimension)
125
- end
123
+ def validate_renderer
124
+ return if Query::RENDERERS.include?(renderer.to_s)
126
125
 
127
- def ransack_name
128
- dimension_definition.ransack_name
126
+ errors.add(:renderer, "must be one of #{Query::RENDERERS.join(', ')}")
129
127
  end
130
128
 
131
- def applicable_filters
132
- return filters if single_value? || time?
129
+ def validate_granularity(dimension_declaration)
130
+ return if granularity.blank?
131
+
132
+ unless Dimension::GRANULARITIES.include?(granularity)
133
+ return errors.add(:granularity, "must be one of #{Dimension::GRANULARITIES.join(', ')}")
134
+ end
135
+ return if dimension_declaration.nil? || dimension_declaration.time?
133
136
 
134
- filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
137
+ errors.add(:granularity, "only applies to a time dimension, and #{dimension} is not one")
135
138
  end
136
139
  end
137
140
  end
@@ -0,0 +1,149 @@
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
+ def filter_params(label)
107
+ return [ nil, nil ] unless clickable?
108
+ return [ "#{ransack_name}_null", "1" ] if label.to_s == Dimension::NONE
109
+
110
+ [ "#{ransack_name}_eq", label.to_s ]
111
+ end
112
+
113
+ # Every label in this pane paired with the filter it toggles, for a chart
114
+ # to look up by label when a bar is clicked.
115
+ def filters_for(labels)
116
+ return {} unless clickable?
117
+
118
+ labels.to_h { |label| [ label.to_s, filter_params(label) ] }
119
+ end
120
+
121
+ # The filter on this pane's own dimension is not applied to its query, but
122
+ # it is what the user clicked here, so the view highlights it.
123
+ def selected_value
124
+ return unless clickable?
125
+ return Dimension::NONE if filter("#{ransack_name}_null").present?
126
+
127
+ filter("#{ransack_name}_eq")
128
+ end
129
+
130
+ private
131
+ def filter(key)
132
+ filters[key] || filters[key.to_sym]
133
+ end
134
+
135
+ def dimension_definition
136
+ definition.dimension!(dimension)
137
+ end
138
+
139
+ def ransack_name
140
+ dimension_definition.ransack_name
141
+ end
142
+
143
+ def applicable_filters
144
+ return filters if single_value? || time?
145
+
146
+ filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
147
+ end
148
+ end
149
+ 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,4 @@
1
+ <%= tag.div class: janela_frame_classes(frame),
2
+ data: { controller: "janela--frame", janela__frame_filters_value: filters.to_json } do %>
3
+ <%= render partial: "janela/frames/pane", collection: frame.panes, as: :pane, locals: { filters: filters, charts: charts } %>
4
+ <% 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 %>