janela 0.1.0 → 0.2.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 +25 -0
- data/README.md +62 -11
- data/app/assets/javascripts/janela/chart_controller.js +9 -3
- data/app/assets/javascripts/janela/dashboard_controller.js +27 -9
- data/app/controllers/janela/{visuals_controller.rb → panes_controller.rb} +6 -4
- data/app/controllers/janela/snapshot_panes_controller.rb +21 -0
- data/app/helpers/janela/dashboard_helper.rb +38 -5
- data/app/jobs/janela/snapshot_job.rb +18 -0
- data/app/models/janela/pane.rb +118 -0
- data/app/models/janela/snapshot.rb +51 -0
- data/app/views/janela/panes/show.html.erb +45 -0
- data/config/routes.rb +7 -1
- data/db/migrate/20260915000001_create_janela_snapshots.rb +13 -0
- data/docs/decisions/004-charts-and-javascript-delivery.md +5 -4
- data/docs/decisions/005-pane-urls-and-mount-path.md +136 -0
- data/docs/decisions/006-time-dimensions-with-groupdate.md +93 -0
- data/docs/decisions/007-ordering-and-limits.md +74 -0
- data/docs/decisions/008-dashboard-filters-in-the-page-url.md +68 -0
- data/docs/decisions/009-snapshots.md +141 -0
- data/docs/decisions/INDEX.md +14 -6
- data/lib/janela/definition.rb +26 -11
- data/lib/janela/dimension.rb +39 -4
- data/lib/janela/measure.rb +6 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +10 -10
- metadata +28 -5
- data/Rakefile +0 -23
- data/app/models/janela/visual.rb +0 -65
- data/app/views/janela/visuals/show.html.erb +0 -36
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f1be37d3c8dc019195214b51f66d263d13032c3f12e44861620fd2d4f1cbe88
|
|
4
|
+
data.tar.gz: be62c3edcf0f95b897c22a7b348b76168398ee6f3c801230236a3195bda9aae6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 63ef09cab8969ea61002ac8273304222ef40d3d11cc5d5470eecab7a26ef57d84186afb44b58b6bcbeb25a336ae16e91fbc45289199a2163ed3a203bef739ff8
|
|
7
|
+
data.tar.gz: 05462c455475cadb4dc633e76ea3181eeba0d169c9f82090a715a39afb375b92adebba3ca4de4db9e41c03d6a3dd53a641017ee5605e3c90280c18ab3c5dba1a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-09-15
|
|
11
|
+
|
|
12
|
+
Breaking. Renames and a new URL scheme while the only installation is the author's own (ADR 005).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- `janela_visual` is now `janela_pane`; `Janela::Visual` is `Janela::Pane`; CSS hooks are `janela-pane`, `janela-chart`, `janela-value`, `janela-empty`.
|
|
17
|
+
- A pane's URL is `/<mount>/<model route key>/<measure>[/<dimension>]?as=bar&q[...]` instead of `/<mount>/visual?model=...`. Frame ids follow the same shape.
|
|
18
|
+
- The gem no longer ships its Rakefile, which referenced the unshipped dummy application.
|
|
19
|
+
- The helper finds the engine's mount in the host's routes, so `mount Janela::Engine => "/reports", as: :reports` works. Documentation and the dummy application mount at `/dashboards`.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- A pane with no dimension renders the measure's single total.
|
|
24
|
+
- Time dimensions: `dimension :placed_on, granularity: :month` buckets with Groupdate (`hour` to `year`), gap-filled, labelled in Ruby. Override per pane with `?granularity=week` or `janela_pane ..., granularity: :week`. Time panes re-scope with the dashboard but are not yet click sources (ADR 006).
|
|
25
|
+
- `as: :line` renderer for time series.
|
|
26
|
+
- Groupdate is a runtime dependency.
|
|
27
|
+
- Published to rubygems.org through trusted publishing; a public demo of `test/dummy` deploys from `main`.
|
|
28
|
+
- Snapshots: `Janela::Snapshot.take` freezes several panes' results at one instant under one set of filters; `janela_snapshot_pane` renders a stored pane, static by nature, at `/snapshots/:id/<model>/<measure>[/<dimension>]`. `Janela::SnapshotJob` for scheduling. First migration: `rails janela:install:migrations` (ADR 009).
|
|
29
|
+
- Dashboard filters live in the page URL as `q[...]`; a reload keeps them and a filtered dashboard is shareable. The server renders the initial state from `params[:q]` (ADR 008).
|
|
30
|
+
- Category panes are ordered by their measure, largest first, in SQL. `?limit=N` / `janela_pane ..., limit: N` keeps the top N (ADR 007).
|
|
31
|
+
- `dimension :customer, through: :customer, column: :name` names a dimension for its meaning while reading another column.
|
|
32
|
+
|
|
33
|
+
|
|
10
34
|
## [0.1.0] - 2026-09-15
|
|
11
35
|
|
|
12
36
|
First alpha, installed from GitHub for testing in a single host application.
|
|
@@ -23,4 +47,5 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
23
47
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
24
48
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
25
49
|
|
|
50
|
+
[0.2.0]: https://github.com/retail-tasker/janela/releases/tag/v0.2.0
|
|
26
51
|
[0.1.0]: https://github.com/retail-tasker/janela/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -21,16 +21,16 @@ Janela's bet: the same dashboard definition should serve two audiences without b
|
|
|
21
21
|
|
|
22
22
|
## Installation
|
|
23
23
|
|
|
24
|
-
Janela is
|
|
24
|
+
Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It has two halves, a gem and an npm package:
|
|
25
25
|
|
|
26
26
|
```ruby
|
|
27
27
|
# Gemfile
|
|
28
|
-
gem "janela",
|
|
28
|
+
gem "janela", "~> 0.2"
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
```ruby
|
|
32
32
|
# config/routes.rb
|
|
33
|
-
mount Janela::Engine => "/
|
|
33
|
+
mount Janela::Engine => "/dashboards" # or /reports, or wherever you like
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
Then register the two Stimulus controllers. How depends on how your app ships JavaScript.
|
|
@@ -79,16 +79,22 @@ class Order < ApplicationRecord
|
|
|
79
79
|
|
|
80
80
|
dimension :status
|
|
81
81
|
dimension :region, through: :customer
|
|
82
|
+
dimension :placed_on, granularity: :month
|
|
82
83
|
end
|
|
83
84
|
end
|
|
84
85
|
```
|
|
85
86
|
|
|
87
|
+
A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start.
|
|
88
|
+
|
|
86
89
|
Then query them:
|
|
87
90
|
|
|
88
91
|
```ruby
|
|
89
92
|
Order.janela.query(:revenue) # => 375
|
|
90
93
|
Order.janela.query(:revenue, by: :status) # => { "paid" => 300, "refunded" => 50, "pending" => 25 }
|
|
91
94
|
Order.janela.query(:revenue, by: :region) # => { "APAC" => 150, "EU" => 225 }
|
|
95
|
+
Order.janela.query(:revenue, by: :placed_on) # => { "Sep 2026" => 375 }
|
|
96
|
+
Order.janela.query(:revenue, by: :placed_on, granularity: :day)
|
|
97
|
+
# => { "2026-09-01" => 100, "2026-09-02" => 50, ... }
|
|
92
98
|
```
|
|
93
99
|
|
|
94
100
|
Filters are [Ransack](https://github.com/activerecord-hackery/ransack) params, so a slicer built with `search_form_for` can pass `params[:q]` straight through:
|
|
@@ -114,21 +120,66 @@ end
|
|
|
114
120
|
|
|
115
121
|
### Dashboards
|
|
116
122
|
|
|
117
|
-
Compose
|
|
123
|
+
Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one re-scopes the others:
|
|
118
124
|
|
|
119
125
|
```erb
|
|
120
126
|
<%= janela_dashboard do %>
|
|
121
127
|
<button type="button" data-action="janela--dashboard#clear">Clear filters</button>
|
|
122
128
|
|
|
123
|
-
<%=
|
|
124
|
-
<%=
|
|
125
|
-
<%=
|
|
129
|
+
<%= janela_pane Order, :revenue %>
|
|
130
|
+
<%= janela_pane Order, :revenue, by: :status, as: :bar %>
|
|
131
|
+
<%= janela_pane Order, :revenue, by: :region %>
|
|
132
|
+
<%= janela_pane Order, :orders, by: :region %>
|
|
126
133
|
<% end %>
|
|
127
134
|
```
|
|
128
135
|
|
|
129
|
-
`as:` is `:table` by default
|
|
136
|
+
A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
|
|
137
|
+
|
|
138
|
+
The dashboard's filters live in the page URL as the same `q[...]` parameters, so a reload keeps them and a filtered dashboard is a link you can send: `/reports/orders?q[status_eq]=paid` renders filtered before any JavaScript runs. A pane ignores filters on its own dimension, so clicking a value re-scopes the rest of the dashboard rather than collapsing the pane you clicked. Time panes re-scope with the others but are not click sources yet; drill-down is the next decision. The selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read. A pane with no matching rows renders a `.janela-empty` paragraph. Only models that declare a `janela` block can be requested over HTTP.
|
|
139
|
+
|
|
140
|
+
### Pane URLs
|
|
141
|
+
|
|
142
|
+
Every pane has its own URL under the mount, and a Turbo Frame in a dashboard loads exactly the same URL a person can open directly:
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
/dashboards/orders/revenue orders revenue
|
|
146
|
+
/dashboards/orders/revenue/status orders revenue by status
|
|
147
|
+
/dashboards/orders/revenue/status?as=bar ... as a bar chart
|
|
148
|
+
/dashboards/orders/revenue/region?q[status_eq]=paid
|
|
149
|
+
orders revenue by region where status is paid
|
|
150
|
+
/dashboards/orders/revenue/placed_on?granularity=week&as=line
|
|
151
|
+
orders revenue by placed_on, per week, as a line
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The model is its route key (`orders`, `sales_orders`), then the measure, then optionally the dimension. Where an analyst would say *by*, the URL has a `/`; *where* is a `q` filter; *as a bar chart* is `?as=bar`; *top ten* is `?limit=10`; *as of* a snapshot is `/snapshots/:id/` in front. Category panes are always ordered by the measure, largest first; time panes are chronological. A pane opened on its own renders inside your application layout with its filters applied, so a filtered pane is a link you can send someone. ADR 005 has the reasoning.
|
|
155
|
+
|
|
156
|
+
### Snapshots
|
|
157
|
+
|
|
158
|
+
A snapshot freezes the results of several panes at one instant, under one set of filters, so an audience sees exactly what was signed off while the live dashboard stays editable. Results are stored, not HTML; a stored pane can still be drawn as a table or a chart.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
bin/rails janela:install:migrations && bin/rails db:migrate
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
Janela::Snapshot.take(name: "September 2026", filters: { status_eq: "paid" }) do |take|
|
|
166
|
+
take.pane Order, :revenue, on: policy_scope(Order)
|
|
167
|
+
take.pane Order, :revenue, by: :status, on: policy_scope(Order)
|
|
168
|
+
take.pane Order, :revenue, by: :placed_on, granularity: :week
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Render a stored pane the same way you render a live one:
|
|
173
|
+
|
|
174
|
+
```erb
|
|
175
|
+
<%= janela_snapshot_pane @snapshot, Order, :revenue, by: :status, as: :bar %>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Stored panes are static by nature: no filter buttons, charts ignore clicks, and the URL says *as of*: `/dashboards/snapshots/42/orders/revenue/status`. Request filters are ignored because the snapshot's were fixed when it was taken.
|
|
179
|
+
|
|
180
|
+
`Janela::SnapshotJob.perform_later(name:, panes: [{ "model" => "orders", "measure" => "revenue", "by" => "status" }])` takes one from serialisable arguments so you can schedule it with whatever runs your jobs. The job uses each model's default scope; if you scope by tenant, write your own job around `Snapshot.take` and pass `on:`.
|
|
130
181
|
|
|
131
|
-
|
|
182
|
+
Who may see a snapshot is your decision. Stored panes go through the same controllers as live ones, so your authentication applies; an external audience gets a page you build over `janela_snapshot_pane` behind whatever share tokens you already trust. ADR 009 has the reasoning.
|
|
132
183
|
|
|
133
184
|
### Securing dashboards
|
|
134
185
|
|
|
@@ -153,7 +204,7 @@ Janela ships the load-bearing core of a BI tool and nothing else. The reasoning
|
|
|
153
204
|
|
|
154
205
|
- **Measures and dimensions are a Ruby DSL on the model**, config-as-code like `routes.rb`. No drag-and-drop designer.
|
|
155
206
|
- **Querying rides on [Ransack](https://github.com/activerecord-hackery/ransack)'s association-path traversal.** Janela does not invent a query language.
|
|
156
|
-
- **Cross-filtering is a Stimulus controller plus Turbo Frames.** Click a value in one
|
|
207
|
+
- **Cross-filtering is a Stimulus controller plus Turbo Frames.** Click a value in one pane, shared filter state updates, every other frame on the page re-renders.
|
|
157
208
|
- **Charts are [Chart.js](https://www.chartjs.org)**, driven by one small Stimulus controller from the same values the tables show. Not a charting engine.
|
|
158
209
|
- **Publishing creates a Snapshot.** An ActiveJob freezes the result set into a new record; the live dashboard stays editable and the published view is a point-in-time fork, not a toggle on the same record. Not built yet.
|
|
159
210
|
|
|
@@ -161,7 +212,7 @@ Deliberately out of scope: report designer UI, natural-language query, a separat
|
|
|
161
212
|
|
|
162
213
|
## Status
|
|
163
214
|
|
|
164
|
-
**v0.
|
|
215
|
+
**v0.2.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering, bar and line charts, pane URLs, shareable dashboard URLs and snapshots work and are covered by unit and real-browser tests. Not yet built: drill-down on time panes, other chart types. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
|
|
165
216
|
|
|
166
217
|
## Development
|
|
167
218
|
|
|
@@ -3,7 +3,7 @@ import { Chart, registerables } from "chart.js"
|
|
|
3
3
|
|
|
4
4
|
Chart.register(...registerables)
|
|
5
5
|
|
|
6
|
-
// Renders one
|
|
6
|
+
// Renders one pane as a chart and turns a click on a bar into the same
|
|
7
7
|
// toggle event a table button emits, so the dashboard controller cannot tell
|
|
8
8
|
// the difference. Turbo replaces the frame on every cross-filter, so the chart
|
|
9
9
|
// is destroyed on disconnect and rebuilt on connect.
|
|
@@ -15,13 +15,19 @@ export default class extends Controller {
|
|
|
15
15
|
type: this.typeValue,
|
|
16
16
|
data: {
|
|
17
17
|
labels: this.labelsValue,
|
|
18
|
-
datasets: [{
|
|
18
|
+
datasets: [{
|
|
19
|
+
label: this.titleValue,
|
|
20
|
+
data: this.valuesValue,
|
|
21
|
+
backgroundColor: this.colours(),
|
|
22
|
+
borderColor: "rgba(54, 162, 235, 0.9)"
|
|
23
|
+
}]
|
|
19
24
|
},
|
|
20
25
|
options: {
|
|
21
26
|
animation: false,
|
|
27
|
+
scales: { y: { beginAtZero: true } },
|
|
22
28
|
plugins: { legend: { display: false } },
|
|
23
29
|
onClick: (_event, elements) => {
|
|
24
|
-
if (elements.length === 0) return
|
|
30
|
+
if (!this.keyValue || elements.length === 0) return
|
|
25
31
|
const value = this.labelsValue[elements[0].index]
|
|
26
32
|
this.dispatch("toggle", { detail: { key: this.keyValue, value } })
|
|
27
33
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
-
// Shared filter state for every
|
|
3
|
+
// Shared filter state for every pane on the page. Clicking a value rewrites
|
|
4
4
|
// each frame's src, and Turbo reloads a frame whenever its src changes, so
|
|
5
5
|
// cross-filtering needs no streams, no sockets and no state library.
|
|
6
6
|
export default class extends Controller {
|
|
7
|
-
static targets = ["
|
|
7
|
+
static targets = ["pane"]
|
|
8
8
|
static values = { filters: Object }
|
|
9
9
|
|
|
10
10
|
// Table buttons send key/value as Stimulus action params; charts dispatch a
|
|
@@ -27,14 +27,32 @@ export default class extends Controller {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
filtersValueChanged() {
|
|
30
|
-
this.
|
|
31
|
-
const url = new URL(
|
|
30
|
+
this.paneTargets.forEach((pane) => {
|
|
31
|
+
const url = new URL(pane.dataset.janelaSrc, window.location.origin)
|
|
32
|
+
this.writeFilters(url)
|
|
33
|
+
if (pane.src !== url.href) pane.src = url.href
|
|
34
|
+
})
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
+
this.syncPageUrl()
|
|
37
|
+
}
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
// The page URL carries the same q[...] the panes do, so a reload or a
|
|
40
|
+
// pasted link opens the dashboard filtered (ADR 008). Replaced rather than
|
|
41
|
+
// pushed: a click is not a place the back button should return to.
|
|
42
|
+
syncPageUrl() {
|
|
43
|
+
const url = new URL(window.location.href)
|
|
44
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
45
|
+
if (key.startsWith("q[")) url.searchParams.delete(key)
|
|
46
|
+
}
|
|
47
|
+
this.writeFilters(url)
|
|
48
|
+
if (url.href !== window.location.href) history.replaceState(history.state, "", url)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Sorted so the browser serialises filters the same way the server does and
|
|
52
|
+
// an unchanged src is never reloaded.
|
|
53
|
+
writeFilters(url) {
|
|
54
|
+
for (const key of Object.keys(this.filtersValue).sort()) {
|
|
55
|
+
url.searchParams.set(`q[${key}]`, this.filtersValue[key])
|
|
56
|
+
}
|
|
39
57
|
}
|
|
40
58
|
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
module Janela
|
|
2
|
-
class
|
|
2
|
+
class PanesController < ApplicationController
|
|
3
3
|
def show
|
|
4
|
-
@
|
|
4
|
+
@pane = Pane.new(
|
|
5
5
|
definition: Janela.definition!(params.require(:model)),
|
|
6
6
|
measure: params.require(:measure).to_sym,
|
|
7
|
-
dimension: params
|
|
7
|
+
dimension: params[:dimension]&.to_sym,
|
|
8
8
|
renderer: params.fetch(:as, "table"),
|
|
9
|
+
granularity: params[:granularity],
|
|
10
|
+
limit: params[:limit],
|
|
9
11
|
filters: filters
|
|
10
12
|
)
|
|
11
13
|
|
|
12
|
-
@result = @
|
|
14
|
+
@result = @pane.result(on: janela_scope(@pane.model))
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
private
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# A pane as it was when a snapshot was taken. Filters in the request are
|
|
3
|
+
# ignored: they were fixed at taking (ADR 009).
|
|
4
|
+
class SnapshotPanesController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
snapshot = Snapshot.find(params.require(:snapshot_id))
|
|
7
|
+
@pane = Pane.new(
|
|
8
|
+
definition: Janela.definition!(params.require(:model)),
|
|
9
|
+
measure: params.require(:measure).to_sym,
|
|
10
|
+
dimension: params[:dimension]&.to_sym,
|
|
11
|
+
renderer: params.fetch(:as, "table"),
|
|
12
|
+
granularity: params[:granularity],
|
|
13
|
+
limit: params[:limit],
|
|
14
|
+
snapshot: snapshot
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
@result = @pane.result
|
|
18
|
+
render "janela/panes/show"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -1,16 +1,49 @@
|
|
|
1
1
|
module Janela
|
|
2
2
|
module DashboardHelper
|
|
3
|
+
# The page URL carries the dashboard's filters as q[...] (ADR 008), so a
|
|
4
|
+
# shared link renders filtered before any JavaScript runs.
|
|
3
5
|
def janela_dashboard(&block)
|
|
4
|
-
tag.div(data: { controller: "janela--dashboard" }, &block)
|
|
6
|
+
tag.div(data: { controller: "janela--dashboard", janela__dashboard_filters_value: janela_page_filters.to_json }, &block)
|
|
5
7
|
end
|
|
6
8
|
|
|
7
|
-
def
|
|
8
|
-
|
|
9
|
+
def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil)
|
|
10
|
+
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
|
|
11
|
+
base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
|
|
12
|
+
src = janela_page_filters.empty? ? base : janela_routes.pane_path(model.model_name.route_key, measure, by, **query, q: janela_page_filters)
|
|
9
13
|
|
|
10
|
-
turbo_frame_tag
|
|
14
|
+
turbo_frame_tag Pane.frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
|
|
11
15
|
src: src,
|
|
12
16
|
loading: :lazy,
|
|
13
|
-
data: { janela__dashboard_target: "
|
|
17
|
+
data: { janela__dashboard_target: "pane", janela_src: base }
|
|
14
18
|
end
|
|
19
|
+
|
|
20
|
+
# A pane as it was when the snapshot was taken: same shape as janela_pane,
|
|
21
|
+
# not part of the live dashboard's filter state (ADR 009).
|
|
22
|
+
def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil)
|
|
23
|
+
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
|
|
24
|
+
src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
|
|
25
|
+
|
|
26
|
+
turbo_frame_tag Pane.frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, snapshot: snapshot),
|
|
27
|
+
src: src, loading: :lazy
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
def janela_page_filters
|
|
32
|
+
@janela_page_filters ||= begin
|
|
33
|
+
q = request.query_parameters["q"]
|
|
34
|
+
q.is_a?(Hash) ? q.sort.to_h : {}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The host chooses where and under what name the engine is mounted, so
|
|
39
|
+
# the route proxy is looked up rather than assumed to be `janela`.
|
|
40
|
+
def janela_routes
|
|
41
|
+
@janela_routes ||= begin
|
|
42
|
+
mount = Rails.application.routes.routes.find { |route| route.app.respond_to?(:app) && route.app.app == Janela::Engine }
|
|
43
|
+
raise Error, "Janela::Engine is not mounted in the host application's routes" unless mount
|
|
44
|
+
|
|
45
|
+
public_send(mount.name)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
15
48
|
end
|
|
16
49
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# Takes a snapshot from serialisable arguments so a host can schedule it
|
|
3
|
+
# with whatever runs its jobs. Each pane uses its model's default scope; a
|
|
4
|
+
# host that scopes by tenant writes its own job around Snapshot.take and
|
|
5
|
+
# passes on: (ADR 009).
|
|
6
|
+
class SnapshotJob < ActiveJob::Base
|
|
7
|
+
def perform(name:, panes:, filters: {})
|
|
8
|
+
Snapshot.take(name: name, filters: filters) do |take|
|
|
9
|
+
panes.each do |pane|
|
|
10
|
+
pane = pane.to_h.stringify_keys
|
|
11
|
+
model = Janela.definition!(pane.fetch("model")).model
|
|
12
|
+
take.pane(model, pane.fetch("measure").to_sym,
|
|
13
|
+
by: pane["by"]&.to_sym, granularity: pane["granularity"], limit: pane["limit"])
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# One pane of a dashboard: a measure, optionally grouped by a dimension,
|
|
3
|
+
# rendered as a single value, a table or a chart. A pane ignores filters on
|
|
4
|
+
# its own dimension so that clicking a value re-scopes the other panes
|
|
5
|
+
# rather than collapsing this one to the value clicked. A pane read from a
|
|
6
|
+
# snapshot shows stored results and cannot be clicked at all.
|
|
7
|
+
class Pane
|
|
8
|
+
RENDERERS = %w[table bar line].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :snapshot
|
|
11
|
+
|
|
12
|
+
# The helper renders the frame and the controller renders its replacement,
|
|
13
|
+
# so both derive the id the same way from the same parameters.
|
|
14
|
+
def self.frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, snapshot: nil)
|
|
15
|
+
parts = [ "janela", ("snapshot_#{snapshot.id}" if snapshot), model.model_name.route_key, measure, by,
|
|
16
|
+
(granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit) ]
|
|
17
|
+
parts.compact.join("_")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, snapshot: nil)
|
|
21
|
+
@definition = definition
|
|
22
|
+
@measure = measure
|
|
23
|
+
@dimension = dimension
|
|
24
|
+
@renderer = renderer.to_s
|
|
25
|
+
@filters = filters
|
|
26
|
+
@snapshot = snapshot
|
|
27
|
+
|
|
28
|
+
raise Error, "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
|
|
32
|
+
|
|
33
|
+
def model
|
|
34
|
+
definition.model
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def single_value?
|
|
38
|
+
dimension.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def time?
|
|
42
|
+
!single_value? && dimension_definition.time?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def frozen?
|
|
46
|
+
!snapshot.nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def granularity
|
|
50
|
+
@granularity || (dimension_definition.granularity if time?)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Clicking a category adds one Ransack condition; clicking a time bucket
|
|
54
|
+
# would need two, and the dashboard toggles one key at a time (ADR 006).
|
|
55
|
+
# A stored pane is the record of a moment and is not clickable (ADR 009).
|
|
56
|
+
def clickable?
|
|
57
|
+
!single_value? && !time? && !frozen?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def chart?
|
|
61
|
+
!single_value? && renderer != "table"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def frame_id
|
|
65
|
+
self.class.frame_id(model: model, measure: measure, by: dimension, as: renderer,
|
|
66
|
+
granularity: @granularity, limit: limit, snapshot: snapshot)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def title
|
|
70
|
+
base = if single_value?
|
|
71
|
+
measure.to_s.humanize
|
|
72
|
+
else
|
|
73
|
+
by = "#{measure.to_s.humanize} by #{dimension.to_s.humanize}"
|
|
74
|
+
time? ? "#{by} per #{granularity}" : by
|
|
75
|
+
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
|
+
|
|
85
|
+
def result(on: nil)
|
|
86
|
+
return snapshot.stored_result(self) if frozen?
|
|
87
|
+
|
|
88
|
+
definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def filter_key
|
|
92
|
+
"#{ransack_name}_eq" if clickable?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The filter on this pane's own dimension is not applied to its query, but
|
|
96
|
+
# it is what the user clicked here, so the view highlights it.
|
|
97
|
+
def selected_value
|
|
98
|
+
return unless clickable?
|
|
99
|
+
|
|
100
|
+
filters[filter_key] || filters[filter_key.to_sym]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
def dimension_definition
|
|
105
|
+
definition.dimension!(dimension)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def ransack_name
|
|
109
|
+
dimension_definition.ransack_name
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def applicable_filters
|
|
113
|
+
return filters if single_value? || time?
|
|
114
|
+
|
|
115
|
+
filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# A snapshot freezes the results of several panes at one instant under one
|
|
3
|
+
# set of filters, so an audience sees exactly what was signed off while the
|
|
4
|
+
# live dashboard stays editable (ADR 009). Results are stored, not HTML; the
|
|
5
|
+
# renderer is chosen by whoever shows a stored pane.
|
|
6
|
+
class Snapshot < ActiveRecord::Base
|
|
7
|
+
attribute :filters, default: -> { {} }
|
|
8
|
+
attribute :panes, default: -> { [] }
|
|
9
|
+
|
|
10
|
+
validates :name, :taken_at, presence: true
|
|
11
|
+
|
|
12
|
+
def self.take(name:, filters: {}, taken_at: Time.current)
|
|
13
|
+
taking = Taking.new(filters.to_h.stringify_keys)
|
|
14
|
+
yield taking
|
|
15
|
+
create!(name: name, taken_at: taken_at, filters: taking.filters, panes: taking.panes)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def stored_result(pane)
|
|
19
|
+
key = pane.lookup_key
|
|
20
|
+
entry = panes.find { |stored| stored.slice(*key.keys) == key }
|
|
21
|
+
raise Error, "snapshot #{id} has no pane #{key.compact.values.join(' ')}" unless entry
|
|
22
|
+
|
|
23
|
+
entry["result"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class Taking
|
|
27
|
+
attr_reader :filters, :panes
|
|
28
|
+
|
|
29
|
+
def initialize(filters)
|
|
30
|
+
@filters = filters
|
|
31
|
+
@panes = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def pane(model, measure, by: nil, granularity: nil, limit: nil, on: nil)
|
|
35
|
+
pane = Pane.new(definition: model.janela, measure: measure, dimension: by,
|
|
36
|
+
granularity: granularity, limit: limit, filters: filters)
|
|
37
|
+
panes << pane.lookup_key.merge("result" => plain(pane.result(on: on)))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
# BigDecimal would otherwise be encoded as a JSON string.
|
|
42
|
+
def plain(result)
|
|
43
|
+
case result
|
|
44
|
+
when Hash then result.transform_values { |v| plain(v) }
|
|
45
|
+
when BigDecimal then result.to_f
|
|
46
|
+
else result
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<%= turbo_frame_tag @pane.frame_id do %>
|
|
2
|
+
<% if @pane.single_value? %>
|
|
3
|
+
<p class="janela-pane janela-value">
|
|
4
|
+
<span class="janela-value-label"><%= @pane.title %></span>
|
|
5
|
+
<strong class="janela-value-number"><%= number_with_delimiter(@result || 0) %></strong>
|
|
6
|
+
</p>
|
|
7
|
+
<% elsif @result.empty? %>
|
|
8
|
+
<p class="janela-pane janela-empty"><%= @pane.title %>: no data</p>
|
|
9
|
+
<% elsif @pane.chart? %>
|
|
10
|
+
<canvas class="janela-pane janela-chart"
|
|
11
|
+
data-controller="janela--chart"
|
|
12
|
+
data-action="janela--chart:toggle->janela--dashboard#toggle"
|
|
13
|
+
data-janela--chart-type-value="<%= @pane.renderer %>"
|
|
14
|
+
data-janela--chart-title-value="<%= @pane.title %>"
|
|
15
|
+
data-janela--chart-key-value="<%= @pane.filter_key %>"
|
|
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
|
+
role="img" aria-label="<%= @pane.title %>"></canvas>
|
|
20
|
+
<% else %>
|
|
21
|
+
<table class="janela-pane">
|
|
22
|
+
<caption><%= @pane.title %></caption>
|
|
23
|
+
<tbody>
|
|
24
|
+
<% @result.each do |value, measured| %>
|
|
25
|
+
<tr>
|
|
26
|
+
<td>
|
|
27
|
+
<% if @pane.clickable? %>
|
|
28
|
+
<button type="button"
|
|
29
|
+
aria-pressed="<%= value.to_s == @pane.selected_value.to_s && @pane.selected_value.present? %>"
|
|
30
|
+
data-action="janela--dashboard#toggle"
|
|
31
|
+
data-janela--dashboard-key-param="<%= @pane.filter_key %>"
|
|
32
|
+
data-janela--dashboard-value-param="<%= value %>">
|
|
33
|
+
<%= value %>
|
|
34
|
+
</button>
|
|
35
|
+
<% else %>
|
|
36
|
+
<span><%= value %></span>
|
|
37
|
+
<% end %>
|
|
38
|
+
</td>
|
|
39
|
+
<td><%= number_with_delimiter(measured) %></td>
|
|
40
|
+
</tr>
|
|
41
|
+
<% end %>
|
|
42
|
+
</tbody>
|
|
43
|
+
</table>
|
|
44
|
+
<% end %>
|
|
45
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
Janela::Engine.routes.draw do
|
|
2
|
-
|
|
2
|
+
segment = /[a-z0-9_]+/
|
|
3
|
+
|
|
4
|
+
get "snapshots/:snapshot_id/:model/:measure(/:dimension)", to: "snapshot_panes#show", as: :snapshot_pane,
|
|
5
|
+
constraints: { snapshot_id: /\d+/, model: segment, measure: segment, dimension: segment }
|
|
6
|
+
|
|
7
|
+
get ":model/:measure(/:dimension)", to: "panes#show", as: :pane,
|
|
8
|
+
constraints: { model: segment, measure: segment, dimension: segment }
|
|
3
9
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateJanelaSnapshots < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :janela_snapshots do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
t.datetime :taken_at, null: false
|
|
6
|
+
t.json :filters, null: false
|
|
7
|
+
t.json :panes, null: false
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :janela_snapshots, :taken_at
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -111,10 +111,11 @@ resolves against the engine's routes. Blazer documents the same
|
|
|
111
111
|
|
|
112
112
|
## Consequences
|
|
113
113
|
|
|
114
|
-
- The npm package's `files` glob excludes `vendor/`,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
- The npm package's `files` glob excludes `vendor/`, which a registry
|
|
115
|
+
install honours. Git-based installs (`github:`, `file:`) copy the
|
|
116
|
+
repository tree as is, so they carry the 200 KB vendored Chart.js
|
|
117
|
+
that a bundler host never imports. Harmless, and gone the day the
|
|
118
|
+
package is published to npm.
|
|
118
119
|
- An importmap host with its own `chart.js` pin keeps it. The check is
|
|
119
120
|
`packages.key?("chart.js")` in the engine's `config/importmap.rb` and
|
|
120
121
|
depends on the host's importmap being evaluated first, which is the
|