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
|
@@ -1,47 +1,4 @@
|
|
|
1
|
-
<% content_for :title, @
|
|
2
|
-
<%= turbo_frame_tag @pane.
|
|
3
|
-
|
|
4
|
-
<p class="janela-pane janela-value">
|
|
5
|
-
<span class="janela-value-label"><%= @pane.title %></span>
|
|
6
|
-
<strong class="janela-value-number"><%= number_with_delimiter(@result || 0) %></strong>
|
|
7
|
-
</p>
|
|
8
|
-
<% elsif @result.empty? %>
|
|
9
|
-
<p class="janela-pane janela-empty"><%= @pane.title %>: no data</p>
|
|
10
|
-
<% elsif @pane.chart? %>
|
|
11
|
-
<canvas class="janela-pane janela-chart"
|
|
12
|
-
data-controller="janela--chart"
|
|
13
|
-
data-action="janela--chart:toggle->janela--dashboard#toggle"
|
|
14
|
-
data-janela--chart-type-value="<%= @pane.renderer %>"
|
|
15
|
-
data-janela--chart-title-value="<%= @pane.title %>"
|
|
16
|
-
data-janela--chart-selected-value="<%= @pane.selected_value %>"
|
|
17
|
-
data-janela--chart-labels-value="<%= @result.keys.to_json %>"
|
|
18
|
-
data-janela--chart-values-value="<%= @result.values.map(&:to_f).to_json %>"
|
|
19
|
-
data-janela--chart-filters-value="<%= @pane.filters_for(@result.keys).to_json %>"
|
|
20
|
-
role="img" aria-label="<%= @pane.title %>"></canvas>
|
|
21
|
-
<% else %>
|
|
22
|
-
<table class="janela-pane">
|
|
23
|
-
<caption><%= @pane.title %></caption>
|
|
24
|
-
<tbody>
|
|
25
|
-
<% @result.each do |label, measured| %>
|
|
26
|
-
<% key, value = @pane.filter_params(label) %>
|
|
27
|
-
<tr>
|
|
28
|
-
<td>
|
|
29
|
-
<% if key %>
|
|
30
|
-
<button type="button"
|
|
31
|
-
aria-pressed="<%= label.to_s == @pane.selected_value.to_s && @pane.selected_value.present? %>"
|
|
32
|
-
data-action="janela--dashboard#toggle"
|
|
33
|
-
data-janela--dashboard-key-param="<%= key %>"
|
|
34
|
-
data-janela--dashboard-value-param="<%= value %>">
|
|
35
|
-
<%= label %>
|
|
36
|
-
</button>
|
|
37
|
-
<% else %>
|
|
38
|
-
<span><%= label %></span>
|
|
39
|
-
<% end %>
|
|
40
|
-
</td>
|
|
41
|
-
<td><%= number_with_delimiter(measured) %></td>
|
|
42
|
-
</tr>
|
|
43
|
-
<% end %>
|
|
44
|
-
</tbody>
|
|
45
|
-
</table>
|
|
46
|
-
<% end %>
|
|
1
|
+
<% content_for :title, @query.title %>
|
|
2
|
+
<%= turbo_frame_tag @pane.turbo_frame_id do %>
|
|
3
|
+
<%= render "janela/queries/query", query: @query, result: @result %>
|
|
47
4
|
<% end %>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<%# One pane's content, without its turbo frame: the same markup whether the
|
|
2
|
+
pane came from a URL, a row, or a frame rendered inline. %>
|
|
3
|
+
<% if query.single_value? %>
|
|
4
|
+
<p class="janela-pane janela-value">
|
|
5
|
+
<span class="janela-value-label"><%= query.title %></span>
|
|
6
|
+
<strong class="janela-value-number"><%= query.format(result || 0) %></strong>
|
|
7
|
+
</p>
|
|
8
|
+
<% elsif result.empty? %>
|
|
9
|
+
<p class="janela-pane janela-empty"><%= query.title %>: no data</p>
|
|
10
|
+
<% elsif query.chart? %>
|
|
11
|
+
<canvas class="janela-pane janela-chart"
|
|
12
|
+
data-controller="janela--chart"
|
|
13
|
+
data-action="janela--chart:toggle->janela--frame#toggle"
|
|
14
|
+
data-janela--chart-type-value="<%= query.renderer %>"
|
|
15
|
+
data-janela--chart-title-value="<%= query.title %>"
|
|
16
|
+
data-janela--chart-selected-value="<%= query.selected_value %>"
|
|
17
|
+
data-janela--chart-labels-value="<%= result.keys.to_json %>"
|
|
18
|
+
data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
|
|
19
|
+
data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
|
|
20
|
+
data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
|
|
21
|
+
role="img" aria-label="<%= query.title %>"></canvas>
|
|
22
|
+
<% else %>
|
|
23
|
+
<table class="janela-pane">
|
|
24
|
+
<caption><%= query.title %></caption>
|
|
25
|
+
<tbody>
|
|
26
|
+
<% result.each do |label, measured| %>
|
|
27
|
+
<% key, value = query.filter_params(label) %>
|
|
28
|
+
<tr>
|
|
29
|
+
<td>
|
|
30
|
+
<% if key %>
|
|
31
|
+
<button type="button"
|
|
32
|
+
aria-pressed="<%= label.to_s == query.selected_value.to_s && query.selected_value.present? %>"
|
|
33
|
+
data-action="janela--frame#toggle"
|
|
34
|
+
data-janela--frame-key-param="<%= key %>"
|
|
35
|
+
data-janela--frame-value-param="<%= value %>">
|
|
36
|
+
<%= label %>
|
|
37
|
+
</button>
|
|
38
|
+
<% else %>
|
|
39
|
+
<span><%= label %></span>
|
|
40
|
+
<% end %>
|
|
41
|
+
</td>
|
|
42
|
+
<td><%= query.format(measured) %></td>
|
|
43
|
+
</tr>
|
|
44
|
+
<% end %>
|
|
45
|
+
</tbody>
|
|
46
|
+
</table>
|
|
47
|
+
<% end %>
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
engine cannot know the host's asset names or
|
|
3
|
-
route helpers do not resolve inside an
|
|
4
|
-
|
|
1
|
+
<%# Janela's own pages and a pane opened directly, outside a Turbo Frame.
|
|
2
|
+
Deliberately minimal: the engine cannot know the host's asset names or
|
|
3
|
+
bundler, and a host layout's route helpers do not resolve inside an
|
|
4
|
+
isolated engine (ADR 011). It loads Janela's own stylesheet, which the gem
|
|
5
|
+
ships and can therefore name, and no JavaScript: Turbo and Stimulus come
|
|
6
|
+
from the host, so these pages are static dashboards rather than broken
|
|
7
|
+
ones. A host can override this file, or point the controller at its own
|
|
8
|
+
layout. %>
|
|
5
9
|
<!DOCTYPE html>
|
|
6
10
|
<html>
|
|
7
11
|
<head>
|
|
@@ -10,9 +14,13 @@
|
|
|
10
14
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
11
15
|
<%= csrf_meta_tags %>
|
|
12
16
|
<%= csp_meta_tag %>
|
|
17
|
+
<%= stylesheet_link_tag "janela" %>
|
|
13
18
|
<%= yield :head %>
|
|
14
19
|
</head>
|
|
15
|
-
<body>
|
|
20
|
+
<body class="janela-page">
|
|
21
|
+
<% flash.each do |_kind, message| %>
|
|
22
|
+
<p class="janela-flash"><%= message %></p>
|
|
23
|
+
<% end %>
|
|
16
24
|
<%= yield %>
|
|
17
25
|
</body>
|
|
18
26
|
</html>
|
data/config/importmap.rb
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
en:
|
|
2
|
+
activerecord:
|
|
3
|
+
models:
|
|
4
|
+
janela/frame:
|
|
5
|
+
one: Frame
|
|
6
|
+
other: Frames
|
|
7
|
+
janela/pane:
|
|
8
|
+
one: Pane
|
|
9
|
+
other: Panes
|
|
10
|
+
attributes:
|
|
11
|
+
janela/frame:
|
|
12
|
+
name: Name
|
|
13
|
+
columns: Columns
|
|
14
|
+
gap: Gap
|
|
15
|
+
janela/pane:
|
|
16
|
+
model: Model
|
|
17
|
+
measure: Measure
|
|
18
|
+
dimension: Dimension
|
|
19
|
+
renderer: Renderer
|
|
20
|
+
granularity: Granularity
|
|
21
|
+
limit: Rows
|
|
22
|
+
span: Width
|
|
23
|
+
title: Title
|
|
24
|
+
janela:
|
|
25
|
+
actions:
|
|
26
|
+
add_pane: "Add a %{name}"
|
|
27
|
+
cancel: Cancel
|
|
28
|
+
continue: Continue
|
|
29
|
+
delete: Remove
|
|
30
|
+
delete_frame: "Delete this %{name}"
|
|
31
|
+
edit: Edit
|
|
32
|
+
move_down: Down
|
|
33
|
+
move_up: Up
|
|
34
|
+
new_frame: "New %{name}"
|
|
35
|
+
save: Save
|
|
36
|
+
view: View
|
|
37
|
+
frames:
|
|
38
|
+
created: Created.
|
|
39
|
+
destroyed: Deleted.
|
|
40
|
+
updated: Saved.
|
|
41
|
+
index:
|
|
42
|
+
blank: "No %{name} yet."
|
|
43
|
+
edit:
|
|
44
|
+
blank: "No %{name} yet."
|
|
45
|
+
frame:
|
|
46
|
+
panes:
|
|
47
|
+
zero: no panes
|
|
48
|
+
one: 1 pane
|
|
49
|
+
other: "%{count} panes"
|
|
50
|
+
changed: "changed %{time} ago"
|
|
51
|
+
panes:
|
|
52
|
+
created: Added.
|
|
53
|
+
destroyed: Removed.
|
|
54
|
+
updated: Saved.
|
|
55
|
+
declared_granularity: As the model declares it
|
|
56
|
+
granularity_hint: "Applies to %{dimensions} only."
|
|
57
|
+
no_dimension: "No breakdown, one number"
|
|
58
|
+
no_limit: Every row
|
|
59
|
+
summary: "%{renderer}, %{span} wide"
|
|
60
|
+
title_placeholder: Janela writes one if you leave this blank
|
|
61
|
+
renderers:
|
|
62
|
+
bar: Bar chart
|
|
63
|
+
line: Line chart
|
|
64
|
+
table: Table
|
data/config/routes.rb
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
Janela::Engine.routes.draw do
|
|
2
2
|
segment = /[a-z0-9_]+/
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
# Frames sit at the mount root and are drawn before the pane grammar, with a
|
|
5
|
+
# numeric constraint: no model's route key is all digits, so /3 is a frame
|
|
6
|
+
# and /orders/revenue is a pane, with nothing to disambiguate (ADR 014).
|
|
7
|
+
resources :frames, path: "", constraints: { id: /\d+/ } do
|
|
8
|
+
# No index: a frame's own edit page is the list of its panes.
|
|
9
|
+
resources :panes, except: :index, constraints: { id: /\d+/, frame_id: /\d+/ } do
|
|
10
|
+
member do
|
|
11
|
+
patch :move_up
|
|
12
|
+
patch :move_down
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
get "snapshots/:snapshot_id/:model/:measure(/:dimension)", to: "snapshot_queries#show", as: :snapshot_pane,
|
|
5
18
|
constraints: { snapshot_id: /\d+/, model: segment, measure: segment, dimension: segment }
|
|
6
19
|
|
|
7
|
-
get ":model/:measure(/:dimension)", to: "
|
|
20
|
+
get ":model/:measure(/:dimension)", to: "queries#show", as: :pane,
|
|
8
21
|
constraints: { model: segment, measure: segment, dimension: segment }
|
|
9
22
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateJanelaFrames < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :janela_frames do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
t.integer :columns, null: false, default: 3
|
|
6
|
+
t.integer :gap, null: false, default: 4
|
|
7
|
+
# Janela never reads the owner. It is here so a host's Pundit Scope has
|
|
8
|
+
# something to filter a frame on (ADR 014).
|
|
9
|
+
t.references :owner, polymorphic: true, null: true
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CreateJanelaPanes < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :janela_panes do |t|
|
|
4
|
+
# The composite index below is the one a frame's panes are read by, so
|
|
5
|
+
# references adds none of its own.
|
|
6
|
+
t.references :frame, null: false, index: false, foreign_key: { to_table: :janela_frames }
|
|
7
|
+
t.integer :position, null: false
|
|
8
|
+
t.integer :span, null: false, default: 1
|
|
9
|
+
# A model's route key, the same identifier a pane URL carries (ADR 005).
|
|
10
|
+
t.string :model, null: false
|
|
11
|
+
t.string :measure, null: false
|
|
12
|
+
t.string :dimension
|
|
13
|
+
t.string :renderer, null: false, default: "table"
|
|
14
|
+
t.string :granularity
|
|
15
|
+
t.integer :limit
|
|
16
|
+
t.string :title
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
add_index :janela_panes, [ :frame_id, :position ]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -71,3 +71,7 @@ Janela is designed to be forked, not just configured.
|
|
|
71
71
|
special-casing Janela itself.
|
|
72
72
|
- Future scope decisions (what's in the 5%, what isn't) should cite
|
|
73
73
|
this ADR rather than re-litigate the philosophy each time.
|
|
74
|
+
- The exclusion of a report designer was partly superseded by ADR 012:
|
|
75
|
+
composition became data, because this ADR assumed the developer
|
|
76
|
+
authored dashboards and the author is the analyst. The reasoning
|
|
77
|
+
still holds for the query vocabulary, which stays in code.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
Date: 2026-09-15
|
|
3
3
|
Status: Accepted
|
|
4
|
-
Related: ADR 001, ADR 002, ADR 005, ADR 008
|
|
4
|
+
Related: ADR 001, ADR 002, ADR 005, ADR 008, ADR 012
|
|
5
|
+
Superseded in part by: ADR 012
|
|
5
6
|
Triggers:
|
|
6
7
|
- publishing a dashboard or pane for an audience that must not see live data or slicers
|
|
7
8
|
- adding a database table, migration or model to the engine
|
|
@@ -53,7 +54,9 @@ several panes frozen at the same instant under the same filters, so a
|
|
|
53
54
|
`Janela::Snapshot` row holds a name, `taken_at`, the filters, and an
|
|
54
55
|
array of pane results. There is no Dashboard model and no dashboard
|
|
55
56
|
DSL: the host's page is the dashboard, and the host names the panes to
|
|
56
|
-
freeze.
|
|
57
|
+
freeze. (Superseded in part by ADR 012: a dashboard is now a record,
|
|
58
|
+
`Janela::Frame`, and a snapshot of a frame rather than of a list is
|
|
59
|
+
the obvious next shape. Nothing about what a snapshot stores changes.) If the host renders its live page from the same list it passes
|
|
57
60
|
here, nothing is declared twice.
|
|
58
61
|
|
|
59
62
|
```ruby
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
Date: 2026-09-15
|
|
3
3
|
Status: Accepted
|
|
4
|
-
Related: ADR 001, ADR 005, ADR 009
|
|
4
|
+
Related: ADR 001, ADR 005, ADR 009, ADR 012, ADR 014
|
|
5
|
+
Superseded in part by: ADR 012
|
|
5
6
|
Triggers:
|
|
6
7
|
- writing or changing guidance for AI agents about using Janela
|
|
7
8
|
- proposing that Janela ship an agent, a skill or an MCP surface to hosts
|
|
@@ -65,9 +66,12 @@ is why it survives whatever agent formats come and go.
|
|
|
65
66
|
4. When to take a snapshot, and that a snapshot holds results, not
|
|
66
67
|
HTML.
|
|
67
68
|
5. The three traps above, each with its one line fix.
|
|
68
|
-
6. What Janela deliberately does not do, so an agent does not build
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
6. What Janela deliberately does not do, so an agent does not build
|
|
70
|
+
natural language query, row level security or a scheduler into the
|
|
71
|
+
host by accident. Composition is no longer on that list: ADR 012
|
|
72
|
+
made frames and panes records and ADR 014 sequenced the editing
|
|
73
|
+
surfaces, so the skill points an agent at those rather than telling
|
|
74
|
+
it to compose dashboards in ERB.
|
|
71
75
|
|
|
72
76
|
**The skill describes the README's API and nothing else.** One API,
|
|
73
77
|
one set of names. If the skill needs to say something the README does
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-15
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001, ADR 002, ADR 005, ADR 009
|
|
5
|
+
Supersedes: part of ADR 001
|
|
6
|
+
Triggers:
|
|
7
|
+
- changing what a frame or a pane stores, or how either is rendered
|
|
8
|
+
- adding an editing surface for dashboards
|
|
9
|
+
- anything that would let a persisted row widen what can be queried
|
|
10
|
+
- deciding whether a dashboard belongs in git
|
|
11
|
+
- adding layout vocabulary, or a layer between a frame and its panes
|
|
12
|
+
- naming anything "frame"
|
|
13
|
+
Topics: frames, panes, persistence, layout, css-grid, naming, authorisation, scope
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# ADR 012: Frames and Panes Are Data
|
|
17
|
+
|
|
18
|
+
## Context
|
|
19
|
+
|
|
20
|
+
ADR 001 put a drag and drop report designer out of scope and made
|
|
21
|
+
dashboards code. A dashboard was an ERB page in the host application
|
|
22
|
+
calling `janela_pane` helpers, which kept it in git, reviewable and
|
|
23
|
+
identical across environments. Everything since has been built on that
|
|
24
|
+
assumption.
|
|
25
|
+
|
|
26
|
+
Dogfooding found the assumption wrong, and the reason is ownership
|
|
27
|
+
rather than convenience. **The author of a dashboard is the analyst.**
|
|
28
|
+
In the commercial application this gem was extracted from, the people
|
|
29
|
+
who decide which panes a dashboard has, in what order and at what
|
|
30
|
+
size, are the people reading it, and they cannot deploy. A composition
|
|
31
|
+
that lives in ERB is a file held by the wrong owner. One install
|
|
32
|
+
measured a fourteen minute round trip, edit, test, lint, CI, deploy,
|
|
33
|
+
poll, to add a single pane; the cost is real, but the ownership is the
|
|
34
|
+
argument.
|
|
35
|
+
|
|
36
|
+
The gem was already half of the way there, and the asymmetry was
|
|
37
|
+
visible once pointed at. A pane is a real URL accepting `as`,
|
|
38
|
+
`granularity`, `limit` and Ransack `q[...]`, so what a pane *shows*
|
|
39
|
+
changes live for anyone with the link (ADR 005, ADR 007, ADR 008).
|
|
40
|
+
Only *composition* was frozen in code.
|
|
41
|
+
|
|
42
|
+
## Decision
|
|
43
|
+
|
|
44
|
+
**A frame is a dashboard, and it is data.** Two records, created at
|
|
45
|
+
runtime, with no requirement to be declared or seeded in code first:
|
|
46
|
+
|
|
47
|
+
- `Janela::Frame`, one dashboard. A name, a slug, and its grid: how
|
|
48
|
+
many columns and what gap.
|
|
49
|
+
- `Janela::Pane`, one visual in one frame. Its position, how many
|
|
50
|
+
columns it spans, and what it shows: model, measure, dimension,
|
|
51
|
+
renderer, granularity, limit, and an optional title of its own.
|
|
52
|
+
|
|
53
|
+
**The data definition drives the HTML.** Janela renders a frame from
|
|
54
|
+
its rows and the host composes nothing:
|
|
55
|
+
|
|
56
|
+
```erb
|
|
57
|
+
<%= janela_frame @frame %>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Grid, order, spans and each pane's Turbo Frame all derive from the
|
|
61
|
+
data. This is the point of the change: the library is data driven and
|
|
62
|
+
the markup fits the definition, rather than the definition being
|
|
63
|
+
markup.
|
|
64
|
+
|
|
65
|
+
**Layout uses CSS Grid's vocabulary, not a new one.** `columns` and
|
|
66
|
+
`gap` on the frame, `span` on a pane. Every Rails developer knows
|
|
67
|
+
these words, they map directly onto what renders with no translation
|
|
68
|
+
layer, and ADR 001 argues against inventing a glossary when a shared
|
|
69
|
+
one exists.
|
|
70
|
+
|
|
71
|
+
**The names come from window anatomy, one level each.** Janela is
|
|
72
|
+
Portuguese for window, so the gem name already carries the metaphor
|
|
73
|
+
and a class called `Window` would stutter. In a window the frame is
|
|
74
|
+
the fixed outer structure, the sash is the movable assembly inside it,
|
|
75
|
+
and the panes are the glass. The frame holds the panes, so `Frame` is
|
|
76
|
+
the container and `Pane` is the visual. `Sash`, a band of panes within
|
|
77
|
+
a frame, is deliberately **not** built: `columns` on the frame plus
|
|
78
|
+
`span` on a pane already express a row of single values, a full width
|
|
79
|
+
time series and a row of breakdowns, which is the dashboard shape this
|
|
80
|
+
project recommends. A sash would be a layer the layout does not need,
|
|
81
|
+
and it stays available if nested bands ever earn one.
|
|
82
|
+
|
|
83
|
+
**"Frame" now means two things, and the rule is written down rather
|
|
84
|
+
than discovered.** `Janela::Frame` is a dashboard. A `<turbo-frame>`
|
|
85
|
+
is the mechanism each pane is rendered inside. They sit one level
|
|
86
|
+
apart, so the frame holds frames, and the ambiguity is real: a
|
|
87
|
+
`Pane belongs_to :frame` gives `pane.frame_id` as a foreign key, which
|
|
88
|
+
collided with the existing `frame_id` meaning the DOM id of a pane's
|
|
89
|
+
turbo frame. That method is renamed `turbo_frame_id`, which is clearer
|
|
90
|
+
regardless. The rule: **frame unqualified means the dashboard; the DOM
|
|
91
|
+
element is always spelled turbo frame.**
|
|
92
|
+
|
|
93
|
+
**The code keeps the vocabulary; the data arranges it.** Measures and
|
|
94
|
+
dimensions stay declared in the `janela` block on the model, in git. A
|
|
95
|
+
`Pane` row may only name a measure and a dimension that block
|
|
96
|
+
declares, because `Janela.definition!` validates against the registry
|
|
97
|
+
and dimensions are themselves the Ransack allowlist (ADR 002, ADR
|
|
98
|
+
003). A row therefore cannot invent a query, reach a model nobody
|
|
99
|
+
exposed, or widen what is filterable. This property is worth stating
|
|
100
|
+
plainly, because it is precisely what is usually wrong with database
|
|
101
|
+
backed dashboards, and here it falls out of what is already built.
|
|
102
|
+
|
|
103
|
+
The division of labour is then: **developers define what can be asked;
|
|
104
|
+
analysts arrange what is shown.**
|
|
105
|
+
|
|
106
|
+
**The runtime object is `Janela::Query`.** Today's `Janela::Pane`
|
|
107
|
+
conflates the thing displayed with the query it runs. With `Pane`
|
|
108
|
+
taken by the persisted record, the runtime object, a definition plus a
|
|
109
|
+
measure, a dimension, filters and a renderer that resolves to a
|
|
110
|
+
result, becomes `Janela::Query`. A `Pane` row builds one to do its
|
|
111
|
+
work.
|
|
112
|
+
|
|
113
|
+
**Three editing surfaces, in this order.** All three are wanted; they
|
|
114
|
+
are not alternatives.
|
|
115
|
+
|
|
116
|
+
1. **Rows.** The models are the foundation, so a host, a script or an
|
|
117
|
+
agent composes a frame with ordinary ActiveRecord. This is the
|
|
118
|
+
whole of the first increment.
|
|
119
|
+
2. **Forms in the engine.** Plain Rails CRUD, no canvas: add a pane by
|
|
120
|
+
choosing from the declared vocabulary, reorder, set a span, set a
|
|
121
|
+
granularity. This is the analyst's minimum and the point at which
|
|
122
|
+
the change delivers what it is for.
|
|
123
|
+
3. **A visual editor.** Accepted as the direction and deferred to its
|
|
124
|
+
own ADR, because it is mostly a JavaScript design problem and
|
|
125
|
+
should not be settled in the same breath as a schema.
|
|
126
|
+
|
|
127
|
+
**Authorisation is the host's, as always.** Reading and editing go
|
|
128
|
+
through Pundit policies on `Janela::Frame` and `Janela::Pane` in the
|
|
129
|
+
host application, the same hook every other part of the gem uses.
|
|
130
|
+
Janela ships no roles.
|
|
131
|
+
|
|
132
|
+
## What this supersedes in ADR 001
|
|
133
|
+
|
|
134
|
+
ADR 001 listed a drag and drop report designer under what Janela would
|
|
135
|
+
deliberately not build, reasoning that dashboards defined in code stay
|
|
136
|
+
small, reviewable and forkable. That reasoning holds for the
|
|
137
|
+
*vocabulary*, which is why measures and dimensions stay in code. It
|
|
138
|
+
does not hold for *composition*, because it assumed the developer was
|
|
139
|
+
the author. Everything else in ADR 001 stands: ship the load bearing
|
|
140
|
+
core, prefer one obvious way, stay decoupled from any host, keep the
|
|
141
|
+
codebase small enough to fork.
|
|
142
|
+
|
|
143
|
+
## Consequences
|
|
144
|
+
|
|
145
|
+
- A dashboard is no longer in git. It cannot be code reviewed,
|
|
146
|
+
diffed, or guaranteed identical between environments, and a
|
|
147
|
+
production frame will drift from anything staging has. This is the
|
|
148
|
+
price of the decision and it is paid knowingly: the owner of a
|
|
149
|
+
dashboard is not a person who works in git.
|
|
150
|
+
- Export and import of a frame will be wanted, to move one between
|
|
151
|
+
environments and to put one under review when it matters. Not built
|
|
152
|
+
now; noted so it is not a surprise.
|
|
153
|
+
- Rendering a frame is a new surface: a helper, a grid, and a
|
|
154
|
+
stylesheet, because a grid with no CSS means nothing. The missing
|
|
155
|
+
default stylesheet (#15) and inter pane layout (#29) become part of
|
|
156
|
+
this work rather than neighbours to it.
|
|
157
|
+
- Snapshots (ADR 009) name their panes explicitly today. A frame gives
|
|
158
|
+
a snapshot an obvious subject: freeze a frame, not a list. A later
|
|
159
|
+
ADR, but the shape is now clear.
|
|
160
|
+
- Renaming the runtime object touches the helper, both controllers,
|
|
161
|
+
the snapshot model, a view and the tests. Mechanical, pre 1.0, and
|
|
162
|
+
invisible to hosts: `janela_pane` keeps its name, so neither
|
|
163
|
+
existing installation changes.
|
|
164
|
+
- Every dashboard built so far, including the demo and both hosts, is
|
|
165
|
+
ERB calling `janela_pane`. That helper keeps working. A frame is an
|
|
166
|
+
addition, not a replacement, and nothing has to move.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-15
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 005, ADR 011, ADR 012
|
|
5
|
+
Triggers:
|
|
6
|
+
- changing how a frame is addressed, or what a frame is called in a host's interface
|
|
7
|
+
- adding a slug, a friendly URL or a lookup other than the primary key
|
|
8
|
+
- adding or replacing the index of frames
|
|
9
|
+
- a frame slug or path segment colliding with the pane grammar
|
|
10
|
+
Topics: routes, urls, naming, i18n, frames, defaults
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 013: Naming and Addressing Frames
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
ADR 012 made a frame a database record. Three questions follow from
|
|
18
|
+
that and none were settled there: what a host calls a frame in its own
|
|
19
|
+
interface, how a frame is addressed, and how a person finds one.
|
|
20
|
+
|
|
21
|
+
The first is the same question ADR 005 answered for the mount path.
|
|
22
|
+
"Frame" is the code's word, taken from window anatomy; it is not a
|
|
23
|
+
word an analyst would use. One host calls these insights, another
|
|
24
|
+
reports, another dashboards, and the difference is business language
|
|
25
|
+
rather than preference.
|
|
26
|
+
|
|
27
|
+
The second has a trap. ADR 005 published the pane grammar as
|
|
28
|
+
`/<mount>/:model/:measure(/:dimension)`. A frame addressed directly
|
|
29
|
+
under the mount, `/insights/vamos-review`, cannot be distinguished
|
|
30
|
+
from the start of a pane URL: Rails cannot tell a frame slug from a
|
|
31
|
+
model route key, and the frame route would shadow every pane.
|
|
32
|
+
|
|
33
|
+
## Decision
|
|
34
|
+
|
|
35
|
+
**What a frame is called comes from i18n, not from configuration.**
|
|
36
|
+
The class stays `Janela::Frame`, and every label the engine renders
|
|
37
|
+
uses `Frame.model_name.human`, so a host renames it in its own locale
|
|
38
|
+
file:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
en:
|
|
42
|
+
activerecord:
|
|
43
|
+
models:
|
|
44
|
+
janela/frame:
|
|
45
|
+
one: "Dashboard"
|
|
46
|
+
other: "Dashboards"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This adds no setting, uses the mechanism Rails already has, and
|
|
50
|
+
composes with real translation rather than only relabelling. Janela
|
|
51
|
+
ships English defaults.
|
|
52
|
+
|
|
53
|
+
**Frames live under their own path segment, and that segment is the
|
|
54
|
+
host's.** A single configurable value, defaulting to `dashboards`
|
|
55
|
+
because that is the word most hosts want and no one should have to
|
|
56
|
+
read the gem to like the default:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
/insights/dashboards every frame
|
|
60
|
+
/insights/dashboards/3 one frame
|
|
61
|
+
/insights/orders/revenue/status a pane, grammar unchanged
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The segment removes the collision entirely, so the pane grammar from
|
|
65
|
+
ADR 005 needs no constraints or disambiguation and stays exactly as
|
|
66
|
+
published.
|
|
67
|
+
|
|
68
|
+
**A frame is addressed by its primary key, the Rails way.**
|
|
69
|
+
`resources :frames` inside the engine, looked up with
|
|
70
|
+
`find(params[:id])`. No slug column, therefore no uniqueness rules, no
|
|
71
|
+
reserved word list, no regeneration on rename, and no second lookup
|
|
72
|
+
path to keep working.
|
|
73
|
+
|
|
74
|
+
If readable URLs are wanted later, the Rails answer is to override
|
|
75
|
+
`to_param` to return `"3-quarterly-review"`. Rails resolves that back
|
|
76
|
+
through `to_i`, so lookups do not change and nothing has to be
|
|
77
|
+
unique. That is an option for a host or a later decision here, not a
|
|
78
|
+
schema.
|
|
79
|
+
|
|
80
|
+
**Using `resources` is also the editing surface.** ADR 012's second
|
|
81
|
+
editing surface, plain forms in the engine, is `index`, `new`, `edit`,
|
|
82
|
+
`update` and `destroy` on that resource. Choosing the conventional
|
|
83
|
+
route shape means the forms are the framework's defaults rather than
|
|
84
|
+
something invented.
|
|
85
|
+
|
|
86
|
+
**Janela ships an index, and a host may ignore it.** The mount's frame
|
|
87
|
+
segment renders a grid of cards, one per frame, with its name, how
|
|
88
|
+
many panes it has and when it changed. That is enough for a host to
|
|
89
|
+
install the gem and navigate on the same day, which is the point.
|
|
90
|
+
|
|
91
|
+
A host that wants something else writes its own page over
|
|
92
|
+
`Janela::Frame.all` and never routes to ours. This is the pattern from
|
|
93
|
+
ADR 011: ship a default that works, let the host replace it, add no
|
|
94
|
+
configuration for the choice.
|
|
95
|
+
|
|
96
|
+
**A richer browse is not this decision.** Rows grouped by category,
|
|
97
|
+
horizontal scrolling and hover previews are a different size of
|
|
98
|
+
feature, and a preview in particular is expensive because a thumbnail
|
|
99
|
+
means rendering panes. The card grid is the default; anything more
|
|
100
|
+
earns its own ADR once a host has enough frames to justify browsing
|
|
101
|
+
rather than listing.
|
|
102
|
+
|
|
103
|
+
## Consequences
|
|
104
|
+
|
|
105
|
+
- Nothing in a host's interface says "frame" unless the host wants it
|
|
106
|
+
to, and the word in the URL is the host's too. The code keeps one
|
|
107
|
+
vocabulary and the reader keeps theirs.
|
|
108
|
+
- One configuration value is added, for the path segment. It is
|
|
109
|
+
justified on the same grounds as ADR 005's mount path: it appears in
|
|
110
|
+
a URL a person reads. The noun in labels deliberately is not
|
|
111
|
+
configuration, because i18n already does it.
|
|
112
|
+
- Integer ids mean a frame URL is not self describing. `to_param` is
|
|
113
|
+
the escape hatch and costs nothing to adopt later.
|
|
114
|
+
- The index is a new view in the engine, so it is more surface to
|
|
115
|
+
style, which reinforces that the default stylesheet (#15) is part of
|
|
116
|
+
this body of work and not adjacent to it.
|
|
117
|
+
- Frames being a conventional Rails resource means the forms surface
|
|
118
|
+
arrives largely for free, which moves ADR 012's second editing stage
|
|
119
|
+
closer than it looked.
|