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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -1
- data/README.md +200 -20
- data/UPGRADING.md +136 -0
- data/app/assets/javascripts/janela/chart_controller.js +12 -5
- data/app/assets/javascripts/janela/{dashboard_controller.js → frame_controller.js} +11 -4
- data/app/assets/stylesheets/janela.css +163 -0
- data/app/controllers/janela/application_controller.rb +18 -0
- data/app/controllers/janela/frames_controller.rb +57 -0
- data/app/controllers/janela/panes_controller.rb +66 -13
- data/app/controllers/janela/queries_controller.rb +17 -0
- data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
- data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +25 -9
- data/app/models/janela/frame.rb +28 -0
- data/app/models/janela/pane.rb +102 -99
- data/app/models/janela/query.rb +149 -0
- data/app/models/janela/snapshot.rb +5 -5
- data/app/views/janela/frames/_card.html.erb +7 -0
- data/app/views/janela/frames/_form.html.erb +23 -0
- data/app/views/janela/frames/_frame.html.erb +4 -0
- data/app/views/janela/frames/_pane.html.erb +12 -0
- data/app/views/janela/frames/edit.html.erb +25 -0
- data/app/views/janela/frames/index.html.erb +15 -0
- data/app/views/janela/frames/new.html.erb +5 -0
- data/app/views/janela/frames/show.html.erb +9 -0
- data/app/views/janela/panes/_form.html.erb +58 -0
- data/app/views/janela/panes/_row.html.erb +12 -0
- data/app/views/janela/panes/edit.html.erb +5 -0
- data/app/views/janela/panes/new.html.erb +22 -0
- data/app/views/janela/panes/show.html.erb +3 -46
- data/app/views/janela/queries/_query.html.erb +47 -0
- data/app/views/janela/queries/show.html.erb +4 -0
- data/app/views/janela/shared/_errors.html.erb +7 -0
- data/app/views/layouts/janela/application.html.erb +13 -5
- data/config/importmap.rb +1 -1
- data/config/locales/en.yml +64 -0
- data/config/routes.rb +15 -2
- data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
- data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
- data/docs/decisions/001-built-to-be-forked.md +4 -0
- data/docs/decisions/009-snapshots.md +5 -2
- data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +8 -4
- data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
- data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
- data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
- data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
- data/docs/decisions/016-the-styling-vocabulary.md +100 -0
- data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
- data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
- data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
- data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
- data/docs/decisions/INDEX.md +22 -7
- data/docs/multi-tenancy.md +175 -0
- data/lib/janela/definition.rb +5 -5
- data/lib/janela/doctor.rb +219 -0
- data/lib/janela/engine.rb +15 -2
- data/lib/janela/measure.rb +65 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +17 -0
- data/lib/tasks/janela.rake +6 -0
- metadata +54 -4
data/app/models/janela/pane.rb
CHANGED
|
@@ -1,137 +1,140 @@
|
|
|
1
1
|
module Janela
|
|
2
|
-
# One pane of a
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
definition.model
|
|
35
|
-
end
|
|
15
|
+
before_validation :assign_position, on: :create
|
|
36
16
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
60
|
+
Query::RENDERERS.include?(renderer.to_s) && renderer.to_s != "table"
|
|
62
61
|
end
|
|
63
62
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
granularity: @granularity, limit: limit, snapshot: snapshot)
|
|
63
|
+
def definition
|
|
64
|
+
Janela.definition!(model)
|
|
67
65
|
end
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
86
|
-
|
|
72
|
+
def panes_below
|
|
73
|
+
frame.panes.where(position: (position + 1)..).order(:position)
|
|
74
|
+
end
|
|
87
75
|
|
|
88
|
-
|
|
89
|
-
|
|
76
|
+
def swap_with(other)
|
|
77
|
+
return false if other.nil?
|
|
90
78
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
88
|
+
def assign_position
|
|
89
|
+
self.position ||= (frame&.panes&.maximum(:position) || 0) + 1
|
|
90
|
+
end
|
|
100
91
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
+
def validate_measure(declaration)
|
|
109
|
+
return if declaration.measures.key?(measure.to_s.to_sym)
|
|
108
110
|
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
116
|
-
|
|
114
|
+
def validate_dimension(declaration)
|
|
115
|
+
return if dimension.blank?
|
|
117
116
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
end
|
|
123
|
+
def validate_renderer
|
|
124
|
+
return if Query::RENDERERS.include?(renderer.to_s)
|
|
126
125
|
|
|
127
|
-
|
|
128
|
-
dimension_definition.ransack_name
|
|
126
|
+
errors.add(:renderer, "must be one of #{Query::RENDERERS.join(', ')}")
|
|
129
127
|
end
|
|
130
128
|
|
|
131
|
-
def
|
|
132
|
-
return
|
|
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
|
-
|
|
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(
|
|
19
|
-
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
|
-
|
|
36
|
-
|
|
37
|
-
panes <<
|
|
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,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 %>
|