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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -1
- data/LICENSE.txt +1 -1
- data/README.md +244 -23
- data/UPGRADING.md +172 -0
- data/app/assets/javascripts/janela/chart_controller.js +23 -10
- data/app/assets/javascripts/janela/frame_controller.js +188 -0
- data/app/assets/javascripts/janela/vitral_controller.js +263 -0
- data/app/assets/stylesheets/janela.css +164 -0
- data/app/assets/stylesheets/vitral.css +343 -0
- data/app/controllers/janela/application_controller.rb +25 -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} +26 -9
- data/app/models/janela/frame.rb +28 -0
- data/app/models/janela/pane.rb +102 -99
- data/app/models/janela/query.rb +162 -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 +5 -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 +22 -5
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +65 -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/022-host-route-helpers-work-inside-the-engine.md +104 -0
- data/docs/decisions/023-vitral-is-a-theme-not-the-stylesheet.md +86 -0
- data/docs/decisions/024-selecting-more-than-one-value.md +127 -0
- data/docs/decisions/INDEX.md +25 -7
- data/docs/multi-tenancy.md +175 -0
- data/docs/naming.md +172 -0
- data/lib/janela/definition.rb +5 -5
- data/lib/janela/doctor.rb +219 -0
- data/lib/janela/engine.rb +24 -2
- data/lib/janela/host_routes.rb +31 -0
- data/lib/janela/measure.rb +65 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +24 -0
- data/lib/tasks/janela.rake +6 -0
- metadata +57 -4
- data/app/assets/javascripts/janela/dashboard_controller.js +0 -58
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# Janela's own pages, so a host can install the gem and navigate the same
|
|
3
|
+
# day (ADR 013), and the analyst's editing surface, as conventional Rails
|
|
4
|
+
# CRUD rather than a canvas (ADR 012, ADR 014). Every action reads through
|
|
5
|
+
# the host's scope rather than Frame.find, so another tenant's frame is a
|
|
6
|
+
# 404 to edit as well as to read.
|
|
7
|
+
class FramesController < ApplicationController
|
|
8
|
+
before_action :set_frame, only: %i[show edit update destroy]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@frames = janela_scope(Frame).includes(:panes).order(:name)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def new
|
|
18
|
+
@frame = Frame.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def edit
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
@frame = Frame.new(frame_params)
|
|
26
|
+
@frame.owner = janela_frame_owner
|
|
27
|
+
|
|
28
|
+
if @frame.save
|
|
29
|
+
redirect_to edit_frame_path(@frame), notice: t("janela.frames.created")
|
|
30
|
+
else
|
|
31
|
+
render :new, status: :unprocessable_entity
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def update
|
|
36
|
+
if @frame.update(frame_params)
|
|
37
|
+
redirect_to frame_path(@frame), notice: t("janela.frames.updated")
|
|
38
|
+
else
|
|
39
|
+
render :edit, status: :unprocessable_entity
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def destroy
|
|
44
|
+
@frame.destroy
|
|
45
|
+
redirect_to frames_path, notice: t("janela.frames.destroyed")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
def set_frame
|
|
50
|
+
@frame = janela_scope(Frame).find(params.require(:id))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def frame_params
|
|
54
|
+
params.expect(frame: [ :name, :columns, :gap ])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -1,23 +1,76 @@
|
|
|
1
1
|
module Janela
|
|
2
|
+
# One pane of a frame: rendered from its row so the row's own title reaches
|
|
3
|
+
# the response (ADR 014), and edited as a nested resource (ADR 012).
|
|
2
4
|
class PanesController < ApplicationController
|
|
5
|
+
before_action :set_frame
|
|
6
|
+
before_action :set_pane, only: %i[show edit update destroy move_up move_down]
|
|
7
|
+
|
|
3
8
|
def show
|
|
4
|
-
@
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
)
|
|
9
|
+
@query = @pane.query(filters: filters)
|
|
10
|
+
@result = @query.result(on: janela_scope(@query.model))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Two steps, because the engine's pages have no JavaScript to refresh one
|
|
14
|
+
# select from another: the first picks a model, the second offers exactly
|
|
15
|
+
# that model's measures and dimensions (ADR 018).
|
|
16
|
+
def new
|
|
17
|
+
@pane = @frame.panes.build(model: params[:model], renderer: "table", span: 1)
|
|
18
|
+
@definition = @pane.model.present? ? Janela.definition!(@pane.model) : nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def edit
|
|
22
|
+
@definition = @pane.definition
|
|
23
|
+
end
|
|
13
24
|
|
|
14
|
-
|
|
25
|
+
def create
|
|
26
|
+
@pane = @frame.panes.build(pane_params)
|
|
27
|
+
|
|
28
|
+
if @pane.save
|
|
29
|
+
redirect_to edit_frame_path(@frame), notice: t("janela.panes.created")
|
|
30
|
+
else
|
|
31
|
+
@definition = @pane.model.present? ? Janela.definition!(@pane.model) : nil
|
|
32
|
+
render :new, status: :unprocessable_entity
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update
|
|
37
|
+
if @pane.update(pane_params)
|
|
38
|
+
redirect_to edit_frame_path(@frame), notice: t("janela.panes.updated")
|
|
39
|
+
else
|
|
40
|
+
@definition = @pane.definition
|
|
41
|
+
render :edit, status: :unprocessable_entity
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def destroy
|
|
46
|
+
@pane.destroy
|
|
47
|
+
@frame.resequence_panes!
|
|
48
|
+
redirect_to edit_frame_path(@frame), notice: t("janela.panes.destroyed")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def move_up
|
|
52
|
+
@pane.move_up
|
|
53
|
+
redirect_to edit_frame_path(@frame)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def move_down
|
|
57
|
+
@pane.move_down
|
|
58
|
+
redirect_to edit_frame_path(@frame)
|
|
15
59
|
end
|
|
16
60
|
|
|
17
61
|
private
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
62
|
+
# Through the host's scope rather than Pane.find, so a frame belonging to
|
|
63
|
+
# another tenant is a 404 for every action (ADR 014).
|
|
64
|
+
def set_frame
|
|
65
|
+
@frame = janela_scope(Frame).find(params.require(:frame_id))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def set_pane
|
|
69
|
+
@pane = @frame.panes.find(params.require(:id))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pane_params
|
|
73
|
+
params.expect(pane: [ :model, :measure, :dimension, :renderer, :granularity, :limit, :span, :title ])
|
|
21
74
|
end
|
|
22
75
|
end
|
|
23
76
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
class QueriesController < ApplicationController
|
|
3
|
+
def show
|
|
4
|
+
@query = Query.new(
|
|
5
|
+
definition: Janela.definition!(params.require(:model)),
|
|
6
|
+
measure: params.require(:measure).to_sym,
|
|
7
|
+
dimension: params[:dimension]&.to_sym,
|
|
8
|
+
renderer: params.fetch(:as, "table"),
|
|
9
|
+
granularity: params[:granularity],
|
|
10
|
+
limit: params[:limit],
|
|
11
|
+
filters: filters
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
@result = @query.result(on: janela_scope(@query.model))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
module Janela
|
|
2
2
|
# A pane as it was when a snapshot was taken. Filters in the request are
|
|
3
3
|
# ignored: they were fixed at taking (ADR 009).
|
|
4
|
-
class
|
|
4
|
+
class SnapshotQueriesController < ApplicationController
|
|
5
5
|
def show
|
|
6
|
-
snapshot
|
|
7
|
-
|
|
6
|
+
# Through the host's scope, so a snapshot its policy hides is a 404
|
|
7
|
+
# rather than a stored result anyone who guesses an id can read (ADR 014).
|
|
8
|
+
snapshot = janela_scope(Snapshot).find(params.require(:snapshot_id))
|
|
9
|
+
@query = Query.new(
|
|
8
10
|
definition: Janela.definition!(params.require(:model)),
|
|
9
11
|
measure: params.require(:measure).to_sym,
|
|
10
12
|
dimension: params[:dimension]&.to_sym,
|
|
@@ -14,8 +16,8 @@ module Janela
|
|
|
14
16
|
snapshot: snapshot
|
|
15
17
|
)
|
|
16
18
|
|
|
17
|
-
@result = @
|
|
18
|
-
render "janela/
|
|
19
|
+
@result = @query.result
|
|
20
|
+
render "janela/queries/show"
|
|
19
21
|
end
|
|
20
22
|
end
|
|
21
23
|
end
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
module Janela
|
|
2
|
-
module
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
module FramesHelper
|
|
3
|
+
# Two ways to supply the panes: a frame record renders itself from its
|
|
4
|
+
# rows (ADR 012), or a block composes janela_pane calls by hand. The page
|
|
5
|
+
# URL carries the filters as q[...] either way (ADR 008), so a shared link
|
|
6
|
+
# renders filtered before any JavaScript runs.
|
|
7
|
+
# charts: false renders a chart pane as its table, for a surface with no
|
|
8
|
+
# chart runtime. The engine's own pages are the case (ADR 018).
|
|
9
|
+
def janela_frame(frame = nil, charts: true, &block)
|
|
10
|
+
return render("janela/frames/frame", frame: frame, filters: janela_page_filters, charts: charts) if frame
|
|
11
|
+
|
|
12
|
+
tag.div(data: { controller: "janela--frame", action: "keydown.esc->janela--frame#clear",
|
|
13
|
+
janela__frame_filters_value: janela_page_filters.to_json }, &block)
|
|
7
14
|
end
|
|
8
15
|
|
|
9
16
|
def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil)
|
|
@@ -11,23 +18,33 @@ module Janela
|
|
|
11
18
|
base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
|
|
12
19
|
src = janela_page_filters.empty? ? base : janela_routes.pane_path(model.model_name.route_key, measure, by, **query, q: janela_page_filters)
|
|
13
20
|
|
|
14
|
-
turbo_frame_tag
|
|
21
|
+
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
|
|
15
22
|
src: src,
|
|
16
23
|
loading: :lazy,
|
|
17
|
-
data: {
|
|
24
|
+
data: { janela__frame_target: "pane", janela_src: base }
|
|
18
25
|
end
|
|
19
26
|
|
|
20
27
|
# A pane as it was when the snapshot was taken: same shape as janela_pane,
|
|
21
|
-
# not part of the live
|
|
28
|
+
# not part of the live frame's filter state (ADR 009).
|
|
22
29
|
def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil)
|
|
23
30
|
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
|
|
24
31
|
src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
|
|
25
32
|
|
|
26
|
-
turbo_frame_tag
|
|
33
|
+
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, snapshot: snapshot),
|
|
27
34
|
src: src, loading: :lazy
|
|
28
35
|
end
|
|
29
36
|
|
|
30
37
|
private
|
|
38
|
+
def janela_frame_classes(frame)
|
|
39
|
+
[ "janela-frame", "janela-cols-#{frame.columns}", "janela-gap-#{frame.gap}" ]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A frame rendered inline runs its queries in the host's own request, so
|
|
43
|
+
# the same Pundit scope the engine's controllers apply is applied here.
|
|
44
|
+
def janela_scope(model)
|
|
45
|
+
respond_to?(:policy_scope, true) ? policy_scope(model) : model.all
|
|
46
|
+
end
|
|
47
|
+
|
|
31
48
|
def janela_page_filters
|
|
32
49
|
@janela_page_filters ||= begin
|
|
33
50
|
q = request.query_parameters["q"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# A dashboard, as data: a name, a grid, and the panes it holds (ADR 012).
|
|
3
|
+
# A frame is created at runtime by a host, a script or an agent, so nothing
|
|
4
|
+
# about a dashboard has to be deployed.
|
|
5
|
+
class Frame < ActiveRecord::Base
|
|
6
|
+
COLUMNS = (1..12).freeze
|
|
7
|
+
GAPS = (0..8).freeze
|
|
8
|
+
|
|
9
|
+
# Janela sets nothing here and reads nothing from it. The column exists so
|
|
10
|
+
# a multi tenant host's Pundit Scope has something to filter on (ADR 014).
|
|
11
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
12
|
+
|
|
13
|
+
has_many :panes, -> { order(:position) }, dependent: :destroy, inverse_of: :frame
|
|
14
|
+
|
|
15
|
+
validates :name, presence: true
|
|
16
|
+
validates :columns, inclusion: { in: COLUMNS }
|
|
17
|
+
validates :gap, inclusion: { in: GAPS }
|
|
18
|
+
|
|
19
|
+
# Positions are kept contiguous so that moving a pane has no gap to fall
|
|
20
|
+
# into and a new pane's position is never a hole. Called after a pane is
|
|
21
|
+
# removed rather than on read, because reading a frame is the common case.
|
|
22
|
+
def resequence_panes!
|
|
23
|
+
panes.order(:position).each_with_index do |pane, index|
|
|
24
|
+
pane.update_columns(position: index + 1) unless pane.position == index + 1
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
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
|