janela 0.1.0 → 0.2.1
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 +41 -0
- data/README.md +64 -11
- data/app/assets/javascripts/janela/chart_controller.js +12 -5
- data/app/assets/javascripts/janela/dashboard_controller.js +27 -9
- data/app/controllers/janela/application_controller.rb +27 -0
- 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 +137 -0
- data/app/models/janela/snapshot.rb +51 -0
- data/app/views/janela/panes/show.html.erb +47 -0
- data/app/views/layouts/janela/application.html.erb +18 -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 +139 -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/010-agent-guidance-ships-the-agent-waits.md +130 -0
- data/docs/decisions/011-panes-do-not-render-in-the-host-layout.md +81 -0
- data/docs/decisions/INDEX.md +19 -7
- data/lib/janela/definition.rb +44 -15
- data/lib/janela/dimension.rb +43 -4
- data/lib/janela/measure.rb +9 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +16 -10
- metadata +31 -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: 86e2bcbf47fdcf475b8e34b2b1897d308a6c9f478e7b3f0903979cd3fa6bb8e2
|
|
4
|
+
data.tar.gz: 9116c50f2f6e92a69489827acf9e9d580954c21bbe068887c3605b96d0531019
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a132498d5553317cd888139ba00559b5c7844c3878bdfe4c0c595783e14e3e4a082ec6fbe0a844d9f07a985a586daf4a4104164ff9dd452ed8ddf4390bdfe630
|
|
7
|
+
data.tar.gz: 5f35f6d28fe84c469e3502f97f8c0ec13b7e1c703f04c5aa21360ef7ad6e14b4cc9c8e8d174e5ac1af9207adcc221d3ed430e2851059ef41f08eb5833637712b
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-09-15
|
|
11
|
+
|
|
12
|
+
Fixes found by dogfooding 0.2.0 in a second application. 0.2.0 is unusable in any host whose layout contains a route helper, which is most of them.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Panes rendered in the host's application layout, so any route helper in it raised `NameError` inside the isolated engine and every pane 500'd. A pane in a Turbo Frame now carries no layout; opened directly it uses Janela's own minimal layout (ADR 011, #19).
|
|
17
|
+
- `average:` or `sum:` over a boolean column returned `true` instead of a ratio, because ActiveRecord casts an aggregate back through the column's type. Declaring one now raises and points at `dimension` instead (#20).
|
|
18
|
+
- A group whose dimension is null rendered as a blank label and filtered on an empty string. It is now labelled `(none)` and toggles Ransack's null predicate (#21).
|
|
19
|
+
- The README claimed time buckets follow `Time.zone`; on SQLite they are UTC, which silently shifts daily buckets by the host's offset (#22).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- A request for a model, measure, dimension or stored pane that does not exist is a 404; a renderer, granularity, limit or filter the request may not use is a 400. The response is a plain sentence, inside the requesting Turbo Frame when there is one, and the detail goes to the log instead of the client. `Janela::NotFound` and `Janela::BadRequest` subclass `Janela::Error`.
|
|
24
|
+
|
|
25
|
+
## [0.2.0] - 2026-09-15
|
|
26
|
+
|
|
27
|
+
Breaking. Renames and a new URL scheme while the only installation is the author's own (ADR 005).
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `janela_visual` is now `janela_pane`; `Janela::Visual` is `Janela::Pane`; CSS hooks are `janela-pane`, `janela-chart`, `janela-value`, `janela-empty`.
|
|
32
|
+
- 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.
|
|
33
|
+
- The gem no longer ships its Rakefile, which referenced the unshipped dummy application.
|
|
34
|
+
- 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`.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- A pane with no dimension renders the measure's single total.
|
|
39
|
+
- 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).
|
|
40
|
+
- `as: :line` renderer for time series.
|
|
41
|
+
- Groupdate is a runtime dependency.
|
|
42
|
+
- Published to rubygems.org through trusted publishing; a public demo of `test/dummy` deploys from `main`.
|
|
43
|
+
- 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).
|
|
44
|
+
- 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).
|
|
45
|
+
- Category panes are ordered by their measure, largest first, in SQL. `?limit=N` / `janela_pane ..., limit: N` keeps the top N (ADR 007).
|
|
46
|
+
- `dimension :customer, through: :customer, column: :name` names a dimension for its meaning while reading another column.
|
|
47
|
+
|
|
48
|
+
|
|
10
49
|
## [0.1.0] - 2026-09-15
|
|
11
50
|
|
|
12
51
|
First alpha, installed from GitHub for testing in a single host application.
|
|
@@ -23,4 +62,6 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
23
62
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
24
63
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
25
64
|
|
|
65
|
+
[0.2.1]: https://github.com/retail-tasker/janela/releases/tag/v0.2.1
|
|
66
|
+
[0.2.0]: https://github.com/retail-tasker/janela/releases/tag/v0.2.0
|
|
26
67
|
[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. **On SQLite, buckets are UTC**, because SQLite cannot convert time zones: with a non-UTC `Time.zone` a daily bucket is shifted by your offset, and an early-morning row lands in the previous day. Coarser granularities blunt the shift without removing it. If you need local-day buckets on SQLite, store a local date column and use it as a plain dimension.
|
|
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,68 @@ 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. A group whose dimension is null is labelled `(none)` and filters with Ransack's null predicate rather than an empty string. 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
|
+
Panes render in Janela's own minimal layout when opened directly, so a shared pane link shows its numbers but no host styling and no charts; set `Janela::ApplicationController.layout "application"` in an initializer to use your own layout, which must not call a bare host route helper (inside an engine those need a `main_app.` prefix). Inside a dashboard, panes are Turbo Frames and carry no layout at all.
|
|
179
|
+
|
|
180
|
+
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.
|
|
181
|
+
|
|
182
|
+
`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
183
|
|
|
131
|
-
|
|
184
|
+
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
185
|
|
|
133
186
|
### Securing dashboards
|
|
134
187
|
|
|
@@ -153,7 +206,7 @@ Janela ships the load-bearing core of a BI tool and nothing else. The reasoning
|
|
|
153
206
|
|
|
154
207
|
- **Measures and dimensions are a Ruby DSL on the model**, config-as-code like `routes.rb`. No drag-and-drop designer.
|
|
155
208
|
- **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
|
|
209
|
+
- **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
210
|
- **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
211
|
- **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
212
|
|
|
@@ -161,7 +214,7 @@ Deliberately out of scope: report designer UI, natural-language query, a separat
|
|
|
161
214
|
|
|
162
215
|
## Status
|
|
163
216
|
|
|
164
|
-
**v0.1
|
|
217
|
+
**v0.2.1 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
218
|
|
|
166
219
|
## Development
|
|
167
220
|
|
|
@@ -3,27 +3,34 @@ 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.
|
|
10
10
|
export default class extends Controller {
|
|
11
|
-
static values = { type: String, labels: Array, values: Array,
|
|
11
|
+
static values = { type: String, labels: Array, values: Array, filters: Object, title: String, selected: String }
|
|
12
12
|
|
|
13
13
|
connect() {
|
|
14
14
|
this.chart = new Chart(this.element, {
|
|
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
30
|
if (elements.length === 0) return
|
|
25
|
-
const
|
|
26
|
-
|
|
31
|
+
const label = this.labelsValue[elements[0].index]
|
|
32
|
+
const [key, value] = this.filtersValue[String(label)] || []
|
|
33
|
+
if (key) this.dispatch("toggle", { detail: { key, value } })
|
|
27
34
|
}
|
|
28
35
|
}
|
|
29
36
|
})
|
|
@@ -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,5 +1,13 @@
|
|
|
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
|
+
rescue_from Janela::BadRequest, with: :janela_bad_request
|
|
10
|
+
|
|
3
11
|
private
|
|
4
12
|
# Pundit defines policy_scope on the host's ApplicationController, which
|
|
5
13
|
# this inherits from, so authorisation applies without Janela depending
|
|
@@ -7,5 +15,24 @@ module Janela
|
|
|
7
15
|
def janela_scope(model)
|
|
8
16
|
respond_to?(:policy_scope, true) ? policy_scope(model) : model.all
|
|
9
17
|
end
|
|
18
|
+
|
|
19
|
+
def janela_not_found(error)
|
|
20
|
+
janela_error(error, :not_found, "There is no such pane.")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def janela_bad_request(error)
|
|
24
|
+
janela_error(error, :bad_request, "That request is not allowed on this pane.")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The detail names models and filter keys, so it goes to the log; the
|
|
28
|
+
# client sees a plain sentence. A Turbo Frame request gets its frame
|
|
29
|
+
# back so the dashboard shows the sentence where the pane would be.
|
|
30
|
+
def janela_error(error, status, message)
|
|
31
|
+
logger.warn("Janela: #{error.message}")
|
|
32
|
+
body = view_context.tag.p(message, class: "janela-pane janela-error")
|
|
33
|
+
frame = request.headers["Turbo-Frame"]
|
|
34
|
+
body = view_context.turbo_frame_tag(frame) { body } if frame.present?
|
|
35
|
+
render html: body, status: status, layout: frame.blank?
|
|
36
|
+
end
|
|
10
37
|
end
|
|
11
38
|
end
|
|
@@ -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,137 @@
|
|
|
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 BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
|
|
29
|
+
@granularity = Dimension.granularity!(granularity) if granularity.present?
|
|
30
|
+
@limit = definition.limit!(limit) if limit.present?
|
|
31
|
+
end
|
|
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
|
+
# The Ransack key and value a click on this label should toggle. A null
|
|
92
|
+
# group filters with the null predicate, not an empty string (ADR 009 has
|
|
93
|
+
# no say here; see issue #21).
|
|
94
|
+
def filter_params(label)
|
|
95
|
+
return [ nil, nil ] unless clickable?
|
|
96
|
+
return [ "#{ransack_name}_null", "1" ] if label.to_s == Dimension::NONE
|
|
97
|
+
|
|
98
|
+
[ "#{ransack_name}_eq", label.to_s ]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Every label in this pane paired with the filter it toggles, for a chart
|
|
102
|
+
# to look up by label when a bar is clicked.
|
|
103
|
+
def filters_for(labels)
|
|
104
|
+
return {} unless clickable?
|
|
105
|
+
|
|
106
|
+
labels.to_h { |label| [ label.to_s, filter_params(label) ] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The filter on this pane's own dimension is not applied to its query, but
|
|
110
|
+
# it is what the user clicked here, so the view highlights it.
|
|
111
|
+
def selected_value
|
|
112
|
+
return unless clickable?
|
|
113
|
+
return Dimension::NONE if filter("#{ransack_name}_null").present?
|
|
114
|
+
|
|
115
|
+
filter("#{ransack_name}_eq")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
def filter(key)
|
|
120
|
+
filters[key] || filters[key.to_sym]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def dimension_definition
|
|
124
|
+
definition.dimension!(dimension)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def ransack_name
|
|
128
|
+
dimension_definition.ransack_name
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def applicable_filters
|
|
132
|
+
return filters if single_value? || time?
|
|
133
|
+
|
|
134
|
+
filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
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 NotFound, "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
|