janela 0.2.0 → 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 +54 -1
- data/README.md +203 -21
- data/UPGRADING.md +136 -0
- data/app/assets/javascripts/janela/chart_controller.js +16 -8
- 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 +45 -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 +107 -85
- data/app/models/janela/query.rb +149 -0
- data/app/models/janela/snapshot.rb +6 -6
- 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 -44
- 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 +26 -0
- 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/005-pane-urls-and-mount-path.md +4 -1
- data/docs/decisions/009-snapshots.md +5 -2
- data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +134 -0
- data/docs/decisions/011-panes-do-not-render-in-the-host-layout.md +81 -0
- 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 +24 -5
- data/docs/multi-tenancy.md +175 -0
- data/lib/janela/definition.rb +23 -9
- data/lib/janela/dimension.rb +5 -1
- data/lib/janela/doctor.rb +219 -0
- data/lib/janela/engine.rb +15 -2
- data/lib/janela/measure.rb +68 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +24 -1
- data/lib/tasks/janela.rake +6 -0
- metadata +57 -4
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/* Janela's own styling, small on purpose and meant to be overridden
|
|
2
|
+
(ADR 016). A host includes it with stylesheet_link_tag "janela"; the engine
|
|
3
|
+
never injects it into a layout it does not own.
|
|
4
|
+
|
|
5
|
+
The names follow one rule, janela-{property}-{scale}, and the scale is
|
|
6
|
+
the integer stored on the record. An analyst's number chooses a rule that
|
|
7
|
+
is already written here rather than reaching CSS as a length, so there is
|
|
8
|
+
nothing to inject and nothing to escape. */
|
|
9
|
+
|
|
10
|
+
:root {
|
|
11
|
+
/* Retheme the whole spacing scale by setting this one value. */
|
|
12
|
+
--janela-space: 0.25rem;
|
|
13
|
+
--janela-line: rgba(128, 128, 128, 0.3);
|
|
14
|
+
--janela-accent: rgb(54, 162, 235);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.janela-frame { display: grid; }
|
|
18
|
+
|
|
19
|
+
/* A grid item that cannot shrink below its content overflows the column, and
|
|
20
|
+
a chart is exactly that. */
|
|
21
|
+
.janela-frame > * { min-width: 0; }
|
|
22
|
+
|
|
23
|
+
.janela-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
|
24
|
+
.janela-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
25
|
+
.janela-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
26
|
+
.janela-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
27
|
+
.janela-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
|
28
|
+
.janela-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
29
|
+
.janela-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
|
30
|
+
.janela-cols-8 { grid-template-columns: repeat(8, minmax(0, 1fr)); }
|
|
31
|
+
.janela-cols-9 { grid-template-columns: repeat(9, minmax(0, 1fr)); }
|
|
32
|
+
.janela-cols-10 { grid-template-columns: repeat(10, minmax(0, 1fr)); }
|
|
33
|
+
.janela-cols-11 { grid-template-columns: repeat(11, minmax(0, 1fr)); }
|
|
34
|
+
.janela-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); }
|
|
35
|
+
|
|
36
|
+
.janela-gap-0 { gap: calc(var(--janela-space) * 0); }
|
|
37
|
+
.janela-gap-1 { gap: calc(var(--janela-space) * 1); }
|
|
38
|
+
.janela-gap-2 { gap: calc(var(--janela-space) * 2); }
|
|
39
|
+
.janela-gap-3 { gap: calc(var(--janela-space) * 3); }
|
|
40
|
+
.janela-gap-4 { gap: calc(var(--janela-space) * 4); }
|
|
41
|
+
.janela-gap-5 { gap: calc(var(--janela-space) * 5); }
|
|
42
|
+
.janela-gap-6 { gap: calc(var(--janela-space) * 6); }
|
|
43
|
+
.janela-gap-7 { gap: calc(var(--janela-space) * 7); }
|
|
44
|
+
.janela-gap-8 { gap: calc(var(--janela-space) * 8); }
|
|
45
|
+
|
|
46
|
+
.janela-span-1 { grid-column: span 1; }
|
|
47
|
+
.janela-span-2 { grid-column: span 2; }
|
|
48
|
+
.janela-span-3 { grid-column: span 3; }
|
|
49
|
+
.janela-span-4 { grid-column: span 4; }
|
|
50
|
+
.janela-span-5 { grid-column: span 5; }
|
|
51
|
+
.janela-span-6 { grid-column: span 6; }
|
|
52
|
+
.janela-span-7 { grid-column: span 7; }
|
|
53
|
+
.janela-span-8 { grid-column: span 8; }
|
|
54
|
+
.janela-span-9 { grid-column: span 9; }
|
|
55
|
+
.janela-span-10 { grid-column: span 10; }
|
|
56
|
+
.janela-span-11 { grid-column: span 11; }
|
|
57
|
+
.janela-span-12 { grid-column: span 12; }
|
|
58
|
+
|
|
59
|
+
/* A dashboard nobody can read on a phone is not a choice worth offering, so
|
|
60
|
+
the collapse is here rather than in the data (ADR 016). */
|
|
61
|
+
@media (max-width: 40rem) {
|
|
62
|
+
.janela-frame { grid-template-columns: minmax(0, 1fr); }
|
|
63
|
+
.janela-frame > * { grid-column: span 1; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Enough base styling that a pane is legible on install (#15). Without it a
|
|
67
|
+
table pane runs its label into its number. */
|
|
68
|
+
.janela-value { margin: 0; display: flex; flex-direction: column; gap: calc(var(--janela-space) * 1); }
|
|
69
|
+
.janela-value-label { font-size: 0.85rem; opacity: 0.7; }
|
|
70
|
+
.janela-value-number { font-size: 2rem; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
71
|
+
|
|
72
|
+
table.janela-pane { width: 100%; border-collapse: collapse; }
|
|
73
|
+
table.janela-pane caption { text-align: left; font-weight: 600; margin-bottom: calc(var(--janela-space) * 2); }
|
|
74
|
+
table.janela-pane td { padding: calc(var(--janela-space) * 1.5) 0; border-top: 1px solid var(--janela-line); }
|
|
75
|
+
table.janela-pane td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
|
76
|
+
table.janela-pane button {
|
|
77
|
+
font: inherit;
|
|
78
|
+
color: inherit;
|
|
79
|
+
background: none;
|
|
80
|
+
border: 0;
|
|
81
|
+
padding: calc(var(--janela-space) * 0.5) calc(var(--janela-space) * 2);
|
|
82
|
+
border-radius: 999px;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
}
|
|
85
|
+
table.janela-pane button:hover { background: var(--janela-line); }
|
|
86
|
+
table.janela-pane button[aria-pressed="true"] { background: var(--janela-accent); color: white; }
|
|
87
|
+
|
|
88
|
+
canvas.janela-chart { width: 100% !important; max-height: 20rem; }
|
|
89
|
+
|
|
90
|
+
/* Janela's own pages (ADR 013). Scoped to a class the engine's layout sets,
|
|
91
|
+
so including this stylesheet changes nothing about a host's own pages. */
|
|
92
|
+
.janela-page {
|
|
93
|
+
max-width: 70rem;
|
|
94
|
+
margin: 0 auto;
|
|
95
|
+
padding: calc(var(--janela-space) * 6);
|
|
96
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
97
|
+
}
|
|
98
|
+
.janela-heading { margin: 0 0 calc(var(--janela-space) * 2); font-size: 1.5rem; }
|
|
99
|
+
.janela-crumb { margin: 0 0 calc(var(--janela-space) * 6); font-size: 0.875rem; opacity: 0.7; }
|
|
100
|
+
.janela-card {
|
|
101
|
+
display: flex;
|
|
102
|
+
flex-direction: column;
|
|
103
|
+
gap: var(--janela-space);
|
|
104
|
+
padding: calc(var(--janela-space) * 4);
|
|
105
|
+
border: 1px solid var(--janela-line);
|
|
106
|
+
border-radius: calc(var(--janela-space) * 2);
|
|
107
|
+
color: inherit;
|
|
108
|
+
text-decoration: none;
|
|
109
|
+
}
|
|
110
|
+
.janela-card:hover { border-color: var(--janela-accent); }
|
|
111
|
+
.janela-card-name { font-weight: 600; }
|
|
112
|
+
.janela-card-meta { font-size: 0.875rem; opacity: 0.7; }
|
|
113
|
+
.janela-subheading { margin: calc(var(--janela-space) * 8) 0 calc(var(--janela-space) * 2); font-size: 1.125rem; }
|
|
114
|
+
.janela-muted, .janela-hint { font-size: 0.875rem; opacity: 0.7; }
|
|
115
|
+
.janela-flash {
|
|
116
|
+
margin: 0 0 calc(var(--janela-space) * 4);
|
|
117
|
+
padding: calc(var(--janela-space) * 3);
|
|
118
|
+
border-radius: calc(var(--janela-space) * 2);
|
|
119
|
+
background: var(--janela-line);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* The analyst's editing surface. Plain HTML controls, because these pages run
|
|
123
|
+
no JavaScript (ADR 018): every form submits and every button is a button. */
|
|
124
|
+
.janela-form { display: flex; flex-direction: column; gap: calc(var(--janela-space) * 4); max-width: 30rem; }
|
|
125
|
+
.janela-field { display: flex; flex-direction: column; gap: var(--janela-space); }
|
|
126
|
+
.janela-field label { font-size: 0.875rem; font-weight: 600; }
|
|
127
|
+
.janela-field input, .janela-field select {
|
|
128
|
+
font: inherit;
|
|
129
|
+
padding: calc(var(--janela-space) * 2);
|
|
130
|
+
border: 1px solid var(--janela-line);
|
|
131
|
+
border-radius: calc(var(--janela-space) * 1.5);
|
|
132
|
+
background: transparent;
|
|
133
|
+
color: inherit;
|
|
134
|
+
}
|
|
135
|
+
.janela-actions { display: flex; flex-wrap: wrap; gap: calc(var(--janela-space) * 3); align-items: center; }
|
|
136
|
+
.janela-actions form { display: inline; }
|
|
137
|
+
.janela-button {
|
|
138
|
+
font: inherit;
|
|
139
|
+
padding: calc(var(--janela-space) * 2) calc(var(--janela-space) * 4);
|
|
140
|
+
border: 1px solid var(--janela-line);
|
|
141
|
+
border-radius: calc(var(--janela-space) * 1.5);
|
|
142
|
+
background: transparent;
|
|
143
|
+
color: inherit;
|
|
144
|
+
text-decoration: none;
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
}
|
|
147
|
+
.janela-button:hover { border-color: var(--janela-accent); }
|
|
148
|
+
.janela-button[disabled] { opacity: 0.4; cursor: default; }
|
|
149
|
+
.janela-danger { color: #b3261e; }
|
|
150
|
+
.janela-errors { margin: 0; padding-left: calc(var(--janela-space) * 5); color: #b3261e; }
|
|
151
|
+
.janela-list { list-style: none; margin: 0; padding: 0; }
|
|
152
|
+
.janela-list-row {
|
|
153
|
+
display: flex;
|
|
154
|
+
flex-wrap: wrap;
|
|
155
|
+
gap: calc(var(--janela-space) * 3);
|
|
156
|
+
align-items: center;
|
|
157
|
+
padding: calc(var(--janela-space) * 3) 0;
|
|
158
|
+
border-top: 1px solid var(--janela-line);
|
|
159
|
+
}
|
|
160
|
+
.janela-list-name { flex: 1 1 12rem; font-weight: 600; }
|
|
161
|
+
|
|
162
|
+
.janela-empty { margin: 0; opacity: 0.7; }
|
|
163
|
+
.janela-error { margin: 0; }
|
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
module Janela
|
|
2
2
|
class ApplicationController < Janela.parent_controller.constantize
|
|
3
|
+
# A frame request needs no layout, since Turbo keeps only the matching
|
|
4
|
+
# frame. A direct request gets Janela's own minimal layout, because a host
|
|
5
|
+
# layout's route helpers cannot resolve inside an isolated engine (ADR 011).
|
|
6
|
+
layout -> { turbo_frame_request? ? false : "janela/application" }
|
|
7
|
+
|
|
8
|
+
rescue_from Janela::NotFound, with: :janela_not_found
|
|
9
|
+
# A frame or a pane row the host's scope cannot see is the same answer as a
|
|
10
|
+
# measure that does not exist, and inside a turbo frame it has to be said
|
|
11
|
+
# in the frame rather than by the host's error page.
|
|
12
|
+
rescue_from ActiveRecord::RecordNotFound, with: :janela_not_found
|
|
13
|
+
rescue_from Janela::BadRequest, with: :janela_bad_request
|
|
14
|
+
|
|
3
15
|
private
|
|
16
|
+
def filters
|
|
17
|
+
q = params[:q]
|
|
18
|
+
q.is_a?(ActionController::Parameters) ? q.permit!.to_h : {}
|
|
19
|
+
end
|
|
20
|
+
|
|
4
21
|
# Pundit defines policy_scope on the host's ApplicationController, which
|
|
5
22
|
# this inherits from, so authorisation applies without Janela depending
|
|
6
23
|
# on Pundit or being configured.
|
|
7
24
|
def janela_scope(model)
|
|
8
25
|
respond_to?(:policy_scope, true) ? policy_scope(model) : model.all
|
|
9
26
|
end
|
|
27
|
+
|
|
28
|
+
# A host that scopes frames by owner would hide a frame created without
|
|
29
|
+
# one, so the analyst's new dashboard would vanish as it was saved.
|
|
30
|
+
# Janela cannot know what owns a frame, so the host says, the same way it
|
|
31
|
+
# says what the scope is: by defining a method. A host with no tenancy
|
|
32
|
+
# defines nothing and gets a nil owner, which is right for it (ADR 014).
|
|
33
|
+
def janela_frame_owner
|
|
34
|
+
super if defined?(super)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def janela_not_found(error)
|
|
38
|
+
janela_error(error, :not_found, "There is no such pane.")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def janela_bad_request(error)
|
|
42
|
+
janela_error(error, :bad_request, "That request is not allowed on this pane.")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The detail names models and filter keys, so it goes to the log; the
|
|
46
|
+
# client sees a plain sentence. A Turbo Frame request gets its frame
|
|
47
|
+
# back so the dashboard shows the sentence where the pane would be.
|
|
48
|
+
def janela_error(error, status, message)
|
|
49
|
+
logger.warn("Janela: #{error.message}")
|
|
50
|
+
body = view_context.tag.p(message, class: "janela-pane janela-error")
|
|
51
|
+
frame = request.headers["Turbo-Frame"]
|
|
52
|
+
body = view_context.turbo_frame_tag(frame) { body } if frame.present?
|
|
53
|
+
render html: body, status: status, layout: frame.blank?
|
|
54
|
+
end
|
|
10
55
|
end
|
|
11
56
|
end
|
|
@@ -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,15 @@
|
|
|
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", janela__frame_filters_value: janela_page_filters.to_json }, &block)
|
|
7
13
|
end
|
|
8
14
|
|
|
9
15
|
def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil)
|
|
@@ -11,23 +17,33 @@ module Janela
|
|
|
11
17
|
base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
|
|
12
18
|
src = janela_page_filters.empty? ? base : janela_routes.pane_path(model.model_name.route_key, measure, by, **query, q: janela_page_filters)
|
|
13
19
|
|
|
14
|
-
turbo_frame_tag
|
|
20
|
+
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
|
|
15
21
|
src: src,
|
|
16
22
|
loading: :lazy,
|
|
17
|
-
data: {
|
|
23
|
+
data: { janela__frame_target: "pane", janela_src: base }
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
# A pane as it was when the snapshot was taken: same shape as janela_pane,
|
|
21
|
-
# not part of the live
|
|
27
|
+
# not part of the live frame's filter state (ADR 009).
|
|
22
28
|
def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil)
|
|
23
29
|
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
|
|
24
30
|
src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
|
|
25
31
|
|
|
26
|
-
turbo_frame_tag
|
|
32
|
+
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, snapshot: snapshot),
|
|
27
33
|
src: src, loading: :lazy
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
private
|
|
37
|
+
def janela_frame_classes(frame)
|
|
38
|
+
[ "janela-frame", "janela-cols-#{frame.columns}", "janela-gap-#{frame.gap}" ]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A frame rendered inline runs its queries in the host's own request, so
|
|
42
|
+
# the same Pundit scope the engine's controllers apply is applied here.
|
|
43
|
+
def janela_scope(model)
|
|
44
|
+
respond_to?(:policy_scope, true) ? policy_scope(model) : model.all
|
|
45
|
+
end
|
|
46
|
+
|
|
31
47
|
def janela_page_filters
|
|
32
48
|
@janela_page_filters ||= begin
|
|
33
49
|
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
|