janela 0.11.0 → 0.13.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 +42 -1
- data/README.md +50 -27
- data/UPGRADING.md +57 -1
- data/app/assets/javascripts/janela/chart_controller.js +6 -1
- data/app/assets/javascripts/janela/frame_controller.js +19 -9
- data/app/assets/stylesheets/janela.css +23 -1
- data/app/assets/stylesheets/vitral.css +21 -0
- data/app/controllers/janela/panes_controller.rb +2 -2
- data/app/controllers/janela/queries_controller.rb +3 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +3 -0
- data/app/helpers/janela/frames_helper.rb +12 -4
- data/app/models/janela/pane.rb +32 -1
- data/app/models/janela/query.rb +116 -4
- data/app/views/janela/panes/_form.html.erb +25 -0
- data/app/views/janela/queries/_query.html.erb +36 -14
- data/config/locales/en.yml +19 -0
- data/db/migrate/20260930000001_add_height_to_janela_panes.rb +7 -0
- data/db/migrate/20260930000002_add_prominence_to_janela_panes.rb +7 -0
- data/db/migrate/20260930000003_add_companions_to_janela_panes.rb +8 -0
- data/docs/agents.md +97 -0
- data/docs/composing.md +14 -14
- data/docs/decisions/024-selecting-more-than-one-value.md +1 -1
- data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +1 -1
- data/docs/decisions/047-a-charts-height-is-one-of-five-steps.md +169 -0
- data/docs/decisions/048-a-frame-can-be-told-to-refresh-and-janela-never-decides-when.md +154 -0
- data/docs/decisions/049-the-null-group-is-one-more-value-in-a-selection.md +143 -0
- data/docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md +124 -0
- data/docs/decisions/051-a-table-can-carry-companion-columns.md +160 -0
- data/docs/decisions/052-how-the-project-is-run.md +93 -0
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +187 -0
- data/docs/decisions/INDEX.md +27 -20
- data/docs/multi-tenancy.md +22 -4
- data/docs/roadmap.md +34 -35
- data/docs/theming.md +10 -4
- data/lib/janela/definition.rb +71 -4
- data/lib/janela/dimension.rb +7 -0
- data/lib/janela/doctor.rb +66 -8
- data/lib/janela/engine.rb +13 -1
- data/lib/janela/measure.rb +56 -7
- data/lib/janela/tools.rb +220 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +1 -0
- metadata +20 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 76a888911baddb25729442ecf07cf8975467f05792eb1881d1a6c9ec1c3d05e9
|
|
4
|
+
data.tar.gz: e3718fafdaf889aed3fd4459b722e5ed0db5b39ecffb771cb4c5599c34c0bfb9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3cb99e213c2afbf7e7a570fe4c02b34f1c07fb6d7dc70e0d0fad4753c50264a3466e6f7661c52fb545a4ffa6b50a8fb1b63a6eed5e3ecbd4dd6b7670f21ed9ec
|
|
7
|
+
data.tar.gz: d9fbcebdd34dd825aeb98c98dc449ce92be38b50b1839e83c838730c0c5e9c3258e42d8410d818702032642496688279c0a9e1c361621d228d50dd7e92b9ea6d
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.13.0] - 2026-10-02
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **The vitral theme draws charts in its own colours.** Bars, doughnut slices and pie slices were the stained glass accent for the first and Janela's neutral set for the other seven; vitral now sets all eight, as soft glass tones: a muted blue, sage, lavender, plum, clay, teal, ochre and dusty rose, with the grey of lead came for anything past the eighth. A first version in saturated jewel tones was rejected by eye as a rainbow against the pale tiles, so these sit at about half the chroma and lean on differences in lightness. They were chosen as an ordered set and checked together, with adjacent colour-vision separation of 12.4 (the target is 8) and adjacent normal-vision separation of 15.8 (the floor is 15). Slot 1 is a softer blue of vitral's own; `--janela-accent` stays the stronger blue for what is selected. A host that uses vitral and has set its own `--janela-series-N` keeps them. No action needed (ADR 026, #67).
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **The engine's own chart partial compiles through Herb.** Rails 8.2 renders HTML templates through Herb, which refuses ERB that writes an attribute's name or sits in attribute position, and `janela/queries/_query.html.erb` did both for the chart's canvas. It is now one `tag.canvas` call that renders the same attributes, so a host on the 8.2 defaults is not broken by Janela's own template. A host that has copied that partial keeps its copy, which Herb will reject if it still has ERB in an attribute position: `bin/rails app:herb:check` on Rails 8.2 lists any. No action needed otherwise.
|
|
17
|
+
- **The doctor no longer calls `Janela::PanesController` a stale name.** It reported an error that the name "is now `Janela::QueriesController`", true from 0.7 until 0.12 added a `Janela::PanesController` back as the stored-pane form's controller, which owns `pane_params`. A host patching that form was told to move the patch to a controller with no `pane_params`, which would have silently stopped its extra pane param saving, and the error failed `janela:doctor` until the check was silenced. It is now a warning that says what the name means today. The check is still silenced by `stale-identifiers`, and `Janela::SnapshotPanesController` is unchanged (#74).
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Tools for an agent: `Janela::Tools`.** An agent in a host had no way to read or arrange a dashboard except by writing `Janela::Frame` and `Janela::Pane` rows from a console. `Janela::Tools.new(scope:)` is plain Ruby with a name, a description and a JSON schema for each of `describe_vocabulary`, `list_frames`, `get_frame` and `read_pane`, and with `write: true`, `add_pane`, `update_pane`, `remove_pane` and `move_pane`. `scope:` is required and takes a model class and returns a relation, the answer `policy_scope` gives, so a read or a change reaches only what the caller may see; leaving it out raises `Janela::Unscoped` when the tools are built. Janela registers nothing, serves nothing and depends on no MCP library: [docs/agents.md](docs/agents.md) shows the adapter for the official Ruby SDK, which Janela's own tests run. No action needed (ADR 053, #73).
|
|
22
|
+
|
|
23
|
+
- **The doctor notices a skipped migration, not only a missing table.** `bin/rails janela:doctor` reported a missing `janela_panes` and said nothing when the table was there but a release had added a column to it, so a host that upgraded and did not run `db:migrate` was told all was well, and met an error the first time a pane form or a stored pane read `height`, `prominence` or `companions`. A new `unmigrated-columns` check lists the missing columns by table and names the two commands. It can be silenced by that name like any other (ADR 021, ADR 035).
|
|
24
|
+
|
|
25
|
+
## [0.12.0] - 2026-10-01
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **A table can carry companion columns.** `janela_pane Order, :expedited_rate, by: :customer, companions: [:orders, :region]`, or the same on a stored pane from the pane form, adds up to three columns beside the label, each a measure or a dimension the model declares, under a new header row. A measure companion is its own number, formatted as it declares: a rate can sit beside the count behind it, which answers whether 75% came from four reviews or forty. A dimension companion shows a value only where every row of that label's group shares one and is blank where they differ, because the alternatives were measured and wrong: grouping by it showed a label twice, and picking one showed an arbitrary value as fact. Order, limit, filters and clicking a label stay the primary measure's; only a table draws companions, a stored snapshot shows the base table, and a time pane may carry measures but not dimensions. Each companion is one more query, hence at most three. It travels in the pane URL as `companions[]=`. Stored panes need a migration; see `UPGRADING.md` (ADR 051, #34).
|
|
30
|
+
|
|
31
|
+
- **A single value can say how prominent it is.** `janela_pane Order, :orders, prominence: 3`, or the same on a stored pane from the pane form, takes one of three steps: 1 is a footnote at `1.25rem`, 2 is what every value already is at `2rem`, and 3 is the hero number at `3.5rem`. The label stays small at every step. Until now every headline number was the same size, so a host that wanted one count larger wrote CSS against `janela-value-number` for each pane, and a stored pane could not say it at all. (The issue also asked for default typography and a hideable label; both had already shipped, with #15 and #26.) With none set nothing changes, and a table, a chart and a ring ignore one. The step travels in the pane URL as `?prominence=`. Stored panes need a migration; see `UPGRADING.md` (ADR 050, #31).
|
|
32
|
+
|
|
33
|
+
- **A `ratio:` measure, for the share of rows where a boolean column is true.** `measure :expedited_rate, ratio: :expedited` reads as `31.2%` in a table cell, a single value and a chart tooltip, and orders, gap fills, cross-filters and snapshots like any other measure. Averaging a boolean column never worked: ActiveRecord casts the answer back to `true`, and it does so for 0% as well, so no rate could read as anything else. Janela refused that, correctly, and the refusal had nowhere to point; it now names `ratio:`. A row where the column is null is left out of the average, as SQL's `AVG` leaves it, and is not counted as a no. The number stored, ordered and compared is the fraction (`0.3119`); only the text is a percentage, to one decimal place unless you declare `precision:`. A ratio takes no `prefix:` or `suffix:` (it raises, so `31.2%%` cannot happen) and no condition form (ADR 038, #27).
|
|
34
|
+
|
|
35
|
+
- **A value and (none) can be selected together.** With a chart or table of A, B and (none), Ctrl or Cmd click on (none) beside A now shows the rows that are A or have nothing, where before selecting (none) cleared A. A plain click still replaces the selection, and Ctrl or Cmd click on (none) again takes it out. It is the union of the two in the URL you already have, `q[channel_in][]=web&q[channel_null]=1`, and the same for a `where:` or `default_where` naming both. That pair used to return no rows at all, because Ransack ANDs what it is given, so nothing that worked depended on it. Two exclusions, `not_in` with `not_null`, still mean neither (ADR 049, #69).
|
|
36
|
+
|
|
37
|
+
- **A chart pane can say how tall it is.** `janela_pane Order, :revenue, by: :placed_on, as: :line, height: 2`, or the same on a stored pane from the pane form, takes one of five steps from about 96px to about 448px, drawn as a box of that height around the chart with the chart filling it. Until now a chart was twice its width up to a cap of 20rem, and a host could lower it from outside but not set it, so a stored frame's only way to a shorter row of charts was to swap them for tables. With no height nothing changes, and a ring, a table or a single value ignores one, so switching a pane's renderer never invalidates it. The step travels in the pane URL as `?height=`. Stored panes need a migration; see `UPGRADING.md` (ADR 047, #24).
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- `Measure#sql_alias` is now `Measure#order_by`, because a ratio has no column alias to name and is ordered by its expression. Internal, with one call site in the gem, so nothing a host declares changes; a fork that called it will need the new name (ADR 038).
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **A Sprockets host on importmap-rails no longer gets a 500 from `javascript_importmap_tags`.** Janela declared its stylesheets precompilable and never its JavaScript, so on Sprockets the first page that rendered the importmap raised `AssetNotPrecompiledError: Asset janela/frame_controller.js was not declared to be precompiled in production`, in development as much as production, and no setting turns it into a warning. The engine now declares the four assets its importmap pins, for a host that uses importmap: a Sprockets host that bundles its own JavaScript is untouched, and so is a Propshaft one. If you worked around it with a manifest entry of your own, you can delete it, and nothing breaks if you leave it (ADR 004, #14).
|
|
46
|
+
|
|
8
47
|
## [0.11.0] - 2026-09-30
|
|
9
48
|
|
|
10
49
|
### Added
|
|
@@ -18,7 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
18
57
|
### Changed
|
|
19
58
|
|
|
20
59
|
- **A range in the URL no longer narrows the time pane it names.** `q[placed_on_gteq]` and `q[placed_on_lt]` from a reader now scope every other pane and leave a time pane on that dimension showing its whole series with the range marked, where before it narrowed that pane's own series. A range a host fixes with `where:` or `default_where` still narrows it. See `UPGRADING.md` (ADR 045).
|
|
21
|
-
- **A bar chart's bars are no longer all one colour.** Each bar is now drawn in the palette colour for its position, so a host that has seen every bar in `--janela-accent` will see the first bar in the accent and the rest in new colours. A line chart is unchanged. To keep the old look, set `--janela-series-2` to `--janela-series-8
|
|
60
|
+
- **A bar chart's bars are no longer all one colour.** Each bar is now drawn in the palette colour for its position, so a host that has seen every bar in `--janela-accent` will see the first bar in the accent and the rest in new colours. A line chart is unchanged. To keep the old look, set `--janela-series-2` to `--janela-series-8`, and `--janela-series-other`, to your accent; see `UPGRADING.md` (ADR 046).
|
|
22
61
|
|
|
23
62
|
### Fixed
|
|
24
63
|
|
|
@@ -234,6 +273,8 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
234
273
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
235
274
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
236
275
|
|
|
276
|
+
[0.13.0]: https://github.com/retail-tasker/janela/releases/tag/v0.13.0
|
|
277
|
+
[0.12.0]: https://github.com/retail-tasker/janela/releases/tag/v0.12.0
|
|
237
278
|
[0.11.0]: https://github.com/retail-tasker/janela/releases/tag/v0.11.0
|
|
238
279
|
[0.10.0]: https://github.com/retail-tasker/janela/releases/tag/v0.10.0
|
|
239
280
|
[0.9.0]: https://github.com/retail-tasker/janela/releases/tag/v0.9.0
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@ PowerBI-style dashboards and cross-filtering slicers, native to Rails and Active
|
|
|
6
6
|
|
|
7
7
|
## First principle
|
|
8
8
|
|
|
9
|
-
Janela is a PowerBI-style library built on Ruby and Stimulus, meant to drop onto any Ruby on Rails application. No JS framework, no build step of its own, no separate frontend app. Just a gem you add to an existing Rails app's Gemfile and two Stimulus controllers that ship with it.
|
|
9
|
+
Janela is a PowerBI-style library built on Ruby and Stimulus, meant to drop onto any Ruby on Rails application. No JS framework, no build step of its own, no separate frontend app. Just a gem you add to an existing Rails app's Gemfile and two Stimulus controllers that ship with it, plus an optional third for the theme.
|
|
10
10
|
|
|
11
11
|
## Why
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ Every Rails BI option today is one of:
|
|
|
14
14
|
|
|
15
15
|
- **SQL-first** (Blazer). Powerful, but the query is a black box to your models and associations.
|
|
16
16
|
- **Admin-panel-first** (RailsAdmin, ActiveAdmin, Motor Admin, Avo). Association-aware filtering, but built for CRUD, not for composing multiple charts that filter each other.
|
|
17
|
-
- **A dead end for cross-filtering**. None of the above
|
|
17
|
+
- **A dead end for cross-filtering**. None of the above are built around clicking one chart to re-scope every other chart on the page. That's the PowerBI/Tableau slicer experience, and it's what Janela is for.
|
|
18
18
|
|
|
19
19
|
Janela's bet: the same dashboard definition should serve two audiences without being two systems.
|
|
20
20
|
|
|
@@ -23,11 +23,11 @@ Janela's bet: the same dashboard definition should serve two audiences without b
|
|
|
23
23
|
|
|
24
24
|
## Installation
|
|
25
25
|
|
|
26
|
-
Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It
|
|
26
|
+
Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It is a gem, plus a JavaScript package installed from GitHub if your app bundles its JavaScript. An app on importmap-rails needs only the gem. It needs Rails 8.0+ and Ruby 3.3+ (see Requirements below).
|
|
27
27
|
|
|
28
28
|
```ruby
|
|
29
29
|
# Gemfile
|
|
30
|
-
gem "janela", "~> 0.
|
|
30
|
+
gem "janela", "~> 0.13"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
@@ -35,6 +35,15 @@ gem "janela", "~> 0.11"
|
|
|
35
35
|
mount Janela::Engine => "/dashboards" # or /reports, or wherever you like
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
Janela reads nothing until your `ApplicationController` says what a visitor may see, so the first visit to `/dashboards` raises `Janela::Unscoped` without it. An app with nothing to hide answers once:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# app/controllers/application_controller.rb
|
|
42
|
+
private def policy_scope(model) = model.all
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If you use stored frames or snapshots, run `bin/rails janela:install:migrations && bin/rails db:migrate` (see Frames below), and finish with `bin/rails janela:doctor`, which checks the install and says what to fix. The rest of this section is the JavaScript.
|
|
46
|
+
|
|
38
47
|
Then register the two Stimulus controllers. How depends on how your app ships JavaScript.
|
|
39
48
|
|
|
40
49
|
**With jsbundling (esbuild, bun, webpack):**
|
|
@@ -55,7 +64,7 @@ The chart controller imports `chart.js`, which is a peer dependency: add `chart.
|
|
|
55
64
|
|
|
56
65
|
**With importmap-rails:**
|
|
57
66
|
|
|
58
|
-
Nothing to install. The engine pins `janela/frame_controller`, `janela/chart_controller` and a vendored `chart.js` for you (your own `chart.js` pin wins if you have one). Register the controllers:
|
|
67
|
+
Nothing to install. The engine pins `janela/frame_controller`, `janela/chart_controller` and a vendored `chart.js` for you (your own `chart.js` pin wins if you have one), and declares them precompilable, so it works the same on Propshaft and on Sprockets. Register the controllers:
|
|
59
68
|
|
|
60
69
|
```js
|
|
61
70
|
// app/javascript/application.js
|
|
@@ -158,6 +167,14 @@ Precision defaults to what the schema already says. Counting rows has no decimal
|
|
|
158
167
|
|
|
159
168
|
Formatting is rendering, never rounding. The number itself reaches a snapshot and an order clause at full precision, so a snapshot taken last month reads back under a format you declare today.
|
|
160
169
|
|
|
170
|
+
**The share of rows where something is true** is a ratio, and it has a measure of its own, because averaging a boolean column does not work: ActiveRecord casts the answer back to `true` (so even 0% reads as `true`), and Janela refuses it.
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
measure :expedited_rate, ratio: :expedited # 31.2%
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
It takes a boolean column, leaves rows where the column is null out of the average (as SQL's `AVG` does) rather than counting them as no, and orders, gap fills, cross-filters and snapshots like any other measure. The number it stores is the fraction, `0.3119`, and only the text a reader sees is a percentage, to one decimal place unless you declare `precision:`. It takes no `prefix:` or `suffix:`, since it is always a percentage, and it takes no condition (`ratio: { status: "paid" }`): where a column is not already a yes or no fact, a dimension gives you the split (ADR 038).
|
|
177
|
+
|
|
161
178
|
Then query them:
|
|
162
179
|
|
|
163
180
|
```ruby
|
|
@@ -211,6 +228,12 @@ A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` ke
|
|
|
211
228
|
|
|
212
229
|
A doughnut or a pie is drawn on the server as SVG with a legend of buttons beside it, so it is in the page before any JavaScript runs and can be operated from the keyboard through the legend. Bars, doughnut slices and pie slices are drawn in a palette of eight colours by position, first to eighth, and every value after the eighth in one neutral, so a ring suits a handful of values: `limit: 8` keeps it readable. A ring cannot show a negative value, so a pane with one is drawn as a table and says so. The palette is `--janela-series-1` to `--janela-series-8` and `--janela-series-other` in [docs/theming.md](docs/theming.md) (ADR 046).
|
|
213
230
|
|
|
231
|
+
**Height.** A bar or line chart is drawn at twice its width, up to 20rem, unless the pane says how tall it is: `height: 1` to `height: 5`, from about 96px to about 448px at the default spacing (`janela_pane Order, :revenue, by: :placed_on, as: :line, height: 2`). Five steps rather than pixels, the same as `span` and `gap`, so a stored pane takes it too, from the pane form, and a host that wants an exact figure sets it against `janela-h-2` in its own CSS. With no height nothing changes. A ring, a table and a single value ignore one (ADR 047).
|
|
232
|
+
|
|
233
|
+
**Prominence.** A single value is `2rem` unless the pane says how much it matters: `prominence: 1` is a footnote at `1.25rem`, `2` is what it already is, and `3` is the hero number at `3.5rem` (`janela_pane Order, :orders, prominence: 3`). Three steps rather than a length, the same as `span` and `height`, so a stored pane takes it too, from the pane form. The label stays small at every step. With none set nothing changes, and a table, a chart and a ring ignore one (ADR 050).
|
|
234
|
+
|
|
235
|
+
**Companion columns.** A table can carry up to three more columns beside its label, each a measure or a dimension the model declares: `janela_pane Order, :expedited_rate, by: :customer, companions: [:orders, :region]` reads as customer, expedited rate, orders and region, under a header row. A measure companion is its own number, formatted as it declares, so a rate can sit beside the count behind it: 75% of four and 75% of forty are different things to act on. A dimension companion shows a value only where every row of that label's group shares one, and is blank where they differ, since grouping by it would show a label twice and picking one would show an arbitrary value as fact. Order, limit, filters and clicking a label are the primary measure's and are unchanged. Only a table draws companions, a stored snapshot shows the base table, and a time pane may carry measures but not dimensions. Each companion is one more query, which is why it is three at most (ADR 051).
|
|
236
|
+
|
|
214
237
|
**Reconfiguring a pane in place**, a renderer toggle, a granularity switcher, a "show top 20" control, takes two things: name the pane with `id:`, then ask the frame to repoint it.
|
|
215
238
|
|
|
216
239
|
```erb
|
|
@@ -308,7 +331,7 @@ The engine serves an index and a page per frame at the mount root, so you can in
|
|
|
308
331
|
|
|
309
332
|
No model's route key is all digits, so a frame id and a pane URL cannot be confused. Both pages read through `policy_scope(Janela::Frame)`, so a frame your scope does not return is a 404 rather than a page, and so is a pane row under it.
|
|
310
333
|
|
|
311
|
-
These pages render in Janela's own minimal layout, which loads the gem's stylesheet and nothing else. It does not load Turbo or Stimulus, because those come from your bundler and the engine cannot name them. So Janela's own pages are correct, styled, **static** dashboards: every pane is rendered inline and the numbers are right, filters in the URL apply, and nothing cross-filters when you click. A chart pane needs Chart.js, so on these pages it
|
|
334
|
+
These pages render in Janela's own minimal layout, which loads the gem's stylesheet and nothing else. It does not load Turbo or Stimulus, because those come from your bundler and the engine cannot name them. So Janela's own pages are correct, styled, **static** dashboards: every pane is rendered inline and the numbers are right, filters in the URL apply, and nothing cross-filters when you click. A chart pane needs Chart.js, so on these pages it renders as its table (ADR 018); put a frame on your own page, where your JavaScript is, for the interactive version. Each page links back to your application's root, which you can rename in your own locale file under `janela.actions.home`.
|
|
312
335
|
|
|
313
336
|
The noun in the headings is `Janela::Frame.model_name.human`, so rename it in your own locale file rather than in a setting:
|
|
314
337
|
|
|
@@ -349,9 +372,9 @@ A host with no tenancy defines nothing, gets a nil owner, and is correct: nothin
|
|
|
349
372
|
|
|
350
373
|
### Filters and clicks
|
|
351
374
|
|
|
352
|
-
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_in][]=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.
|
|
375
|
+
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_in][]=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. Clicking a bucket on a time pane filters the others to the range it covers. Every selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read.
|
|
353
376
|
|
|
354
|
-
**Selecting more than one.** Ctrl or Cmd click adds a value to the selection and takes it out again, leaving the rest alone, which is how every list in every operating system already behaves. A plain click selects one value and replaces whatever was selected, or clears the dimension if that value was the only one. It works the same on a chart. All of it works from the keyboard too: a value is a real `<button>`, so Enter is a click and Ctrl or Cmd with Enter adds. `Escape` clears the frame's filters, and those are the only two keys Janela binds, both only while focus is inside the frame, because a single letter belongs to your application and to any text field on the page (ADR 024). 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.
|
|
377
|
+
**Selecting more than one.** Ctrl or Cmd click adds a value to the selection and takes it out again, leaving the rest alone, which is how every list in every operating system already behaves. A plain click selects one value and replaces whatever was selected, or clears the dimension if that value was the only one. It works the same on a chart. The (none) group, the rows that have nothing there, is a member of the selection like any value: Ctrl or Cmd click it beside a value and the panes show the rows that have that value or nothing (`q[channel_in][]=web&q[channel_null]=1`), where `not_in` and `not_null` together still mean neither (ADR 049). All of it works from the keyboard too: a value is a real `<button>`, so Enter is a click and Ctrl or Cmd with Enter adds. `Escape` clears the frame's filters, and those are the only two keys Janela binds, both only while focus is inside the frame, because a single letter belongs to your application and to any text field on the page (ADR 024). 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.
|
|
355
378
|
|
|
356
379
|
### Pane URLs
|
|
357
380
|
|
|
@@ -404,7 +427,7 @@ Render a stored pane the same way you render a live one:
|
|
|
404
427
|
<%= janela_snapshot_pane @snapshot, Order, :revenue, by: :status, as: :bar %>
|
|
405
428
|
```
|
|
406
429
|
|
|
407
|
-
Inside a dashboard a pane is a Turbo Frame and carries no layout at all. Opened directly it renders in Janela's own minimal layout, which
|
|
430
|
+
Inside a dashboard a pane is a Turbo Frame and carries no layout at all. Opened directly it renders in Janela's own minimal layout, which loads the gem's stylesheet and none of your JavaScript, because the gem cannot know your asset names or whether you bundle. A direct pane link therefore shows its numbers styled, and a bar or line chart draws nothing, since the chart needs Stimulus. A table and a ring (which is drawn as SVG on the server) still show.
|
|
408
431
|
|
|
409
432
|
To make direct pane links styled and chart-capable, give Janela a small layout of your own that loads your assets and nothing else:
|
|
410
433
|
|
|
@@ -473,25 +496,19 @@ Scoping is automatic when you use Pundit: `Janela::ApplicationController` calls
|
|
|
473
496
|
|
|
474
497
|
**Multi tenancy** has its own guide: [docs/multi-tenancy.md](docs/multi-tenancy.md). It covers what goes through your scope, worked wiring for Pundit, acts_as_tenant and CanCanCan, what owns a frame the analyst creates, and what rows a scheduled snapshot freezes.
|
|
475
498
|
|
|
476
|
-
###
|
|
499
|
+
### Giving an agent access
|
|
477
500
|
|
|
478
|
-
|
|
479
|
-
work at all, which is enough to navigate on the day you install it:
|
|
501
|
+
Frames and panes are rows, so an agent can arrange a dashboard if it has tools. Janela gives you the tool definitions as plain Ruby and leaves the registering to you, because your application knows who is asking and Janela does not (ADR 053):
|
|
480
502
|
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
```
|
|
503
|
+
```ruby
|
|
504
|
+
tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) }) # read only
|
|
505
|
+
tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) }, write: true)
|
|
485
506
|
|
|
486
|
-
|
|
507
|
+
Janela::Tools.all # name, description, input_schema, read_only
|
|
508
|
+
tools.call("read_pane", pane_id: 4)
|
|
509
|
+
```
|
|
487
510
|
|
|
488
|
-
|
|
489
|
-
cannot know your asset names or bundler. Two consequences worth knowing. They
|
|
490
|
-
do not cross-filter, since that needs Stimulus. And a pane whose row asks for a
|
|
491
|
-
chart renders as its **table** here, because there is no chart runtime on the
|
|
492
|
-
page and a table needs nothing: the same frame rendered in your own page with
|
|
493
|
-
`janela_frame(@frame)` draws the chart. A renderer is a viewing choice, not part
|
|
494
|
-
of the pane (ADR 018).
|
|
511
|
+
`scope:` is required and is the same answer your `policy_scope` gives, so an agent reads and changes only what the person it acts for can. The read tools list the vocabulary and frames and read a pane's values. The write tools add, change, remove and move panes, and are off unless asked for. Janela serves no MCP endpoint, edits no configuration and depends on no MCP library; [docs/agents.md](docs/agents.md) shows the few lines that register them with the official Ruby SDK.
|
|
495
512
|
|
|
496
513
|
### Checking an installation
|
|
497
514
|
|
|
@@ -544,11 +561,11 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
544
561
|
|
|
545
562
|
## Status
|
|
546
563
|
|
|
547
|
-
**v0.
|
|
564
|
+
**v0.13.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar, line, doughnut and pie charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames found by owner and key, panes that hold words or a host partial as well as a query, a host-fixed filter no click can remove and a frame's own permanent one beside it, STI subclasses, the engine's own pages for reading and editing them, tools an agent can use to read and arrange a dashboard, and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, and narrowing a time pane's own granularity by clicking one of its buckets (a click on a bucket does filter the others). [Vista](docs/roadmap.md), the roadmap, says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
|
|
548
565
|
|
|
549
566
|
## Development
|
|
550
567
|
|
|
551
|
-
Janela is a Rails engine. It ships with a minimal host application in `test/dummy` that mounts the engine at `/
|
|
568
|
+
Janela is a Rails engine. It ships with a minimal host application in `test/dummy` that mounts the engine at `/dashboards`, so the gem is always developed and tested against a real Rails app with a real (SQLite) database.
|
|
552
569
|
|
|
553
570
|
After checking out the repo, run `bin/setup` to install dependencies. Then:
|
|
554
571
|
|
|
@@ -561,7 +578,13 @@ bin/rails console # console inside the dummy app, engine loaded
|
|
|
561
578
|
|
|
562
579
|
## Contributing
|
|
563
580
|
|
|
564
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/retail-tasker/janela. Pull requests are reviewed on the merits of the diff, whether a person or an agent wrote them. Contributors are expected to adhere to the [code of conduct](https://github.com/retail-tasker/janela/blob/main/CODE_OF_CONDUCT.md).
|
|
581
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/retail-tasker/janela. Pull requests are reviewed on the merits of the diff, whether a person or an agent wrote them. [CONTRIBUTING.md](https://github.com/retail-tasker/janela/blob/main/CONTRIBUTING.md) says how to run the tests, when a change needs a decision record first, and what to write down for the people who upgrade. Contributors are expected to adhere to the [code of conduct](https://github.com/retail-tasker/janela/blob/main/CODE_OF_CONDUCT.md).
|
|
582
|
+
|
|
583
|
+
## Support and security
|
|
584
|
+
|
|
585
|
+
Janela is pre-1.0 and its public surface can still move between releases, with an upgrade note each time. Issues and pull requests are read when the maintainers can get to them; there is no support contract and no promised response time.
|
|
586
|
+
|
|
587
|
+
To report a vulnerability, use the private form described in [SECURITY.md](https://github.com/retail-tasker/janela/blob/main/SECURITY.md) and not a public issue. Releases are cut by the two maintainers and published from a protected workflow; the steps are in [RELEASING.md](https://github.com/retail-tasker/janela/blob/main/RELEASING.md).
|
|
565
588
|
|
|
566
589
|
## License
|
|
567
590
|
|
data/UPGRADING.md
CHANGED
|
@@ -12,6 +12,57 @@ bin/rails janela:doctor
|
|
|
12
12
|
|
|
13
13
|
It reads your application and lists what still needs changing.
|
|
14
14
|
|
|
15
|
+
## 0.12.0 to 0.13.0
|
|
16
|
+
|
|
17
|
+
No migration, and nothing you must change. Two things you may see.
|
|
18
|
+
|
|
19
|
+
**The doctor treats `Janela::PanesController` as a warning.**
|
|
20
|
+
|
|
21
|
+
0.7 renamed it to `Janela::QueriesController`, and 0.12 added a
|
|
22
|
+
`Janela::PanesController` back as the stored-pane form's controller, which owns
|
|
23
|
+
`pane_params`. The doctor used to call every mention of the name an error. It now
|
|
24
|
+
warns, and says what the name means today. If you patch the form's
|
|
25
|
+
`pane_params`, leave it where it is. If you patched how a query renders, that
|
|
26
|
+
patch belongs on `Janela::QueriesController`.
|
|
27
|
+
|
|
28
|
+
**A host that copied `janela/queries/_query.html.erb` should check it.**
|
|
29
|
+
|
|
30
|
+
Rails 8.2 renders HTML templates through Herb, which refuses ERB that writes an
|
|
31
|
+
attribute's name or sits in attribute position. Janela's own copy of the partial
|
|
32
|
+
no longer does either, so it compiles. A copy of yours keeps the old markup,
|
|
33
|
+
which Herb will reject if it has ERB in an attribute position. On Rails 8.2:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bin/rails herb:check
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
lists what it rejects. On earlier Rails there is nothing to do.
|
|
40
|
+
|
|
41
|
+
**Optional: tools for an agent.**
|
|
42
|
+
|
|
43
|
+
`Janela::Tools` gives an agent the tools to read and arrange a dashboard. It is
|
|
44
|
+
not on unless you build it, and nothing registers itself. See
|
|
45
|
+
[docs/agents.md](docs/agents.md).
|
|
46
|
+
|
|
47
|
+
## 0.11.0 to 0.12.0
|
|
48
|
+
|
|
49
|
+
One migration step, if you use stored frames.
|
|
50
|
+
|
|
51
|
+
**Take the pane migrations.**
|
|
52
|
+
|
|
53
|
+
A pane can now carry a chart height (ADR 047), a single value a
|
|
54
|
+
prominence (ADR 050) and a table companion columns (ADR 051).
|
|
55
|
+
`janela_panes` gains a nullable `height`, a nullable `prominence` and a
|
|
56
|
+
nullable JSON `companions`:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bin/rails janela:install:migrations
|
|
60
|
+
bin/rails db:migrate
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Every existing pane keeps all three nil and is drawn exactly as before. If you
|
|
64
|
+
never use stored frames, there is nothing to do.
|
|
65
|
+
|
|
15
66
|
## 0.10.0 to 0.11.0
|
|
16
67
|
|
|
17
68
|
No migration. Two things you may see.
|
|
@@ -44,7 +95,7 @@ in `--janela-series-2` to `--janela-series-8`, and every bar after the
|
|
|
44
95
|
eighth in `--janela-series-other`. Until now every bar was the accent.
|
|
45
96
|
Line charts are unchanged.
|
|
46
97
|
|
|
47
|
-
If you want the old look, set the
|
|
98
|
+
If you want the old look, set the other eight to your accent:
|
|
48
99
|
|
|
49
100
|
```css
|
|
50
101
|
:root {
|
|
@@ -530,6 +581,11 @@ Most applications do not.
|
|
|
530
581
|
`Janela::Pane` is reserved for a database record in a later release,
|
|
531
582
|
which is why the runtime object had to give the name up.
|
|
532
583
|
|
|
584
|
+
`Janela::PanesController` was renamed in this table, and 0.12 added a
|
|
585
|
+
`Janela::PanesController` back: the stored-pane form's controller, which owns
|
|
586
|
+
`pane_params`. The doctor warns about the name without calling it an error,
|
|
587
|
+
because only you know which one your code means.
|
|
588
|
+
|
|
533
589
|
If you override Janela's view, move your copy from
|
|
534
590
|
`app/views/janela/panes/` to `app/views/janela/queries/`.
|
|
535
591
|
|
|
@@ -9,7 +9,7 @@ Chart.register(...registerables)
|
|
|
9
9
|
// chart is destroyed on disconnect and rebuilt on connect.
|
|
10
10
|
export default class extends Controller {
|
|
11
11
|
static values = { type: String, labels: Array, values: Array, filters: Object, title: String,
|
|
12
|
-
selected: Array, formatted: Array }
|
|
12
|
+
selected: Array, formatted: Array, fixedHeight: Boolean }
|
|
13
13
|
|
|
14
14
|
connect() {
|
|
15
15
|
this.chart = new Chart(this.element, {
|
|
@@ -26,6 +26,11 @@ export default class extends Controller {
|
|
|
26
26
|
},
|
|
27
27
|
options: {
|
|
28
28
|
animation: false,
|
|
29
|
+
// A pane with a height is drawn into a box of that height, and the
|
|
30
|
+
// aspect ratio has to be off for the chart to fill it (ADR 047). It
|
|
31
|
+
// is decided here, when the chart is made: patched onto a chart built
|
|
32
|
+
// with it on, it draws at the wrong size.
|
|
33
|
+
maintainAspectRatio: !this.fixedHeightValue,
|
|
29
34
|
scales: { y: { beginAtZero: true } },
|
|
30
35
|
// A line is clicked anywhere along its x position rather than on
|
|
31
36
|
// the exact pixel of a point, which on a dense series is a few
|
|
@@ -81,22 +81,32 @@ export default class extends Controller {
|
|
|
81
81
|
if (range) return this.toggleRange(range)
|
|
82
82
|
|
|
83
83
|
const additive = event.ctrlKey || event.metaKey || event.detail?.additive === true
|
|
84
|
-
const
|
|
85
|
-
const
|
|
84
|
+
const held = this.filtersValue
|
|
85
|
+
const filters = { ...held }
|
|
86
|
+
const isNone = key.endsWith("_null")
|
|
87
|
+
const base = key.replace(/_(in|null|eq)$/, "")
|
|
88
|
+
const valueKey = isNone ? `${base}_in` : key
|
|
89
|
+
const heldValues = this.valuesFor(held, valueKey)
|
|
90
|
+
const holdsNone = this.valuesFor(held, `${base}_null`).length > 0
|
|
91
|
+
const selected = isNone ? holdsNone : heldValues.includes(String(value))
|
|
86
92
|
|
|
87
|
-
// The null group asks for rows that have nothing there, so it cannot be
|
|
88
|
-
// combined with a value: Ransack ands its conditions, and the pair matches
|
|
89
|
-
// no row at all. It is exclusive within its dimension instead.
|
|
90
93
|
this.clearDimension(filters, key)
|
|
91
94
|
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
// The null group is one more member of the selection, so a plain click
|
|
96
|
+
// replaces all of it and Ctrl or Cmd adds to it, values and null alike.
|
|
97
|
+
// Read together the two mean the union, which is the server's to say
|
|
98
|
+
// (ADR 049). Until then it was exclusive, because ANDed they match no row.
|
|
99
|
+
let values = additive ? heldValues : []
|
|
100
|
+
let none = additive ? holdsNone : false
|
|
101
|
+
if (isNone) {
|
|
102
|
+
none = !selected
|
|
94
103
|
} else {
|
|
95
|
-
let values = additive ? this.valuesFor(this.filtersValue, key) : []
|
|
96
104
|
values = selected ? values.filter((each) => each !== String(value)) : [ ...values, String(value) ]
|
|
97
|
-
if (values.length) filters[key] = [ ...new Set(values) ].sort()
|
|
98
105
|
}
|
|
99
106
|
|
|
107
|
+
if (values.length) filters[valueKey] = [ ...new Set(values) ].sort()
|
|
108
|
+
if (none) filters[`${base}_null`] = "1"
|
|
109
|
+
|
|
100
110
|
this.filtersValue = filters
|
|
101
111
|
}
|
|
102
112
|
|
|
@@ -82,6 +82,11 @@
|
|
|
82
82
|
.janela-value { margin: 0; display: flex; flex-direction: column; gap: calc(var(--janela-space) * 1); }
|
|
83
83
|
.janela-value-label { font-size: 0.85rem; opacity: 0.7; }
|
|
84
84
|
.janela-value-number { font-size: 2rem; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
85
|
+
/* How prominent a single value is (ADR 050). Step 2 is what every value is
|
|
86
|
+
without one. The label stays the same small size at every step. */
|
|
87
|
+
.janela-prominence-1 .janela-value-number { font-size: 1.25rem; }
|
|
88
|
+
.janela-prominence-2 .janela-value-number { font-size: 2rem; }
|
|
89
|
+
.janela-prominence-3 .janela-value-number { font-size: 3.5rem; }
|
|
85
90
|
|
|
86
91
|
/* Words in a stored frame (ADR 039). A heading and paragraphs, spaced by the
|
|
87
92
|
same unit as everything else and otherwise left to the page. */
|
|
@@ -92,7 +97,12 @@
|
|
|
92
97
|
table.janela-pane { width: 100%; border-collapse: collapse; }
|
|
93
98
|
table.janela-pane caption { text-align: left; font-weight: 600; margin-bottom: calc(var(--janela-space) * 2); }
|
|
94
99
|
table.janela-pane td { padding: calc(var(--janela-space) * 1.5) 0; border-top: 1px solid var(--janela-line); }
|
|
95
|
-
table.janela-pane td:
|
|
100
|
+
table.janela-pane td:not(:first-child) { text-align: right; font-variant-numeric: tabular-nums; }
|
|
101
|
+
/* A companion column (ADR 051): a header row names the columns, a measure's
|
|
102
|
+
numbers sit right like the primary's, and a dimension's fact is a word and
|
|
103
|
+
sits left. */
|
|
104
|
+
table.janela-pane th { text-align: right; font-size: 0.85rem; font-weight: 600; opacity: 0.7; padding-bottom: calc(var(--janela-space) * 1); }
|
|
105
|
+
table.janela-pane th:first-child, table.janela-pane th.janela-fact, table.janela-pane td.janela-fact { text-align: left; font-variant-numeric: normal; }
|
|
96
106
|
table.janela-pane button {
|
|
97
107
|
font: inherit;
|
|
98
108
|
color: inherit;
|
|
@@ -108,6 +118,18 @@ table.janela-pane button[aria-pressed="true"] { background: var(--janela-accent)
|
|
|
108
118
|
.janela-chart-title { font-weight: 600; margin-bottom: calc(var(--janela-space) * 2); }
|
|
109
119
|
canvas.janela-chart { width: 100% !important; max-height: 20rem; }
|
|
110
120
|
|
|
121
|
+
/* A pane with a height draws its chart into a box of fixed size (ADR 047), five
|
|
122
|
+
steps of the spacing unit so a theme that moves the unit moves them. Step 4
|
|
123
|
+
is the 20rem the cap allows a chart anyway. The cap stops applying inside a
|
|
124
|
+
box, where the box is the size. */
|
|
125
|
+
.janela-chart-box { position: relative; }
|
|
126
|
+
.janela-chart-box canvas.janela-chart { max-height: none; }
|
|
127
|
+
.janela-h-1 { height: calc(var(--janela-space) * 24); }
|
|
128
|
+
.janela-h-2 { height: calc(var(--janela-space) * 40); }
|
|
129
|
+
.janela-h-3 { height: calc(var(--janela-space) * 56); }
|
|
130
|
+
.janela-h-4 { height: calc(var(--janela-space) * 80); }
|
|
131
|
+
.janela-h-5 { height: calc(var(--janela-space) * 112); }
|
|
132
|
+
|
|
111
133
|
/* A chart pane is a <figure>, and a browser gives a figure 40px of margin
|
|
112
134
|
either side. Harmless in a wide column and most of a narrow one. */
|
|
113
135
|
figure.janela-pane { margin: 0; }
|
|
@@ -69,6 +69,27 @@
|
|
|
69
69
|
janela.css draws agree with the parts this one does. */
|
|
70
70
|
--janela-line: var(--vitral-came-soft);
|
|
71
71
|
--janela-accent: rgb(40, 110, 205);
|
|
72
|
+
|
|
73
|
+
/* The colours a bar, a doughnut slice or a pie slice is drawn in, by
|
|
74
|
+
position (ADR 046, ADR 026). Soft glass rather than bright: a first
|
|
75
|
+
version in saturated jewel tones read as a rainbow against the pale tiles,
|
|
76
|
+
so these sit at about half the chroma and lean on differences in lightness
|
|
77
|
+
to keep neighbours apart (the plum and the dusty rose are the darker ones).
|
|
78
|
+
Chosen as an ordered set and run through the palette checker: adjacent
|
|
79
|
+
colour vision separation 12.4 (target 8) and adjacent normal vision
|
|
80
|
+
separation 15.8 (floor 15). The order is what keeps neighbours apart, so a
|
|
81
|
+
reorder has to be re-checked. Slot 1 is a softer blue of its own and not
|
|
82
|
+
the accent, which stays the stronger blue for what is selected. The neutral
|
|
83
|
+
past the eighth is the grey of lead came. */
|
|
84
|
+
--janela-series-1: #608fcb;
|
|
85
|
+
--janela-series-2: #56ab81;
|
|
86
|
+
--janela-series-3: #a492da;
|
|
87
|
+
--janela-series-4: #91507d;
|
|
88
|
+
--janela-series-5: #be774e;
|
|
89
|
+
--janela-series-6: #159da9;
|
|
90
|
+
--janela-series-7: #c29e51;
|
|
91
|
+
--janela-series-8: #994b59;
|
|
92
|
+
--janela-series-other: #8794a6;
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
/* Opt in, because a theme that repaints a host's <body> uninvited is not a
|
|
@@ -76,8 +76,8 @@ module Janela
|
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def pane_params
|
|
79
|
-
params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :span, :title,
|
|
80
|
-
:heading, :body, :link, :partial ])
|
|
79
|
+
params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :height, :prominence, :span, :title,
|
|
80
|
+
:heading, :body, :link, :partial, { companions: [] } ])
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
def build_pane(choice)
|
|
@@ -8,6 +8,9 @@ module Janela
|
|
|
8
8
|
renderer: params.fetch(:as, "table"),
|
|
9
9
|
granularity: params[:granularity],
|
|
10
10
|
limit: params[:limit],
|
|
11
|
+
height: params[:height],
|
|
12
|
+
prominence: params[:prominence],
|
|
13
|
+
companions: params[:companions],
|
|
11
14
|
filters: filters,
|
|
12
15
|
fixed: fixed_filters
|
|
13
16
|
)
|
|
@@ -26,8 +26,15 @@ module Janela
|
|
|
26
26
|
# so a host that changes the query in place (a renderer, granularity or
|
|
27
27
|
# limit control) keeps one stable frame for Turbo to reconcile into
|
|
28
28
|
# rather than a different id every time the query changes (ADR 029).
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
#
|
|
30
|
+
# prominence: is one of three steps for a single value, and height: one of
|
|
31
|
+
# five for a chart. Both travel in the pane's URL for the same reason.
|
|
32
|
+
# height: is one of five steps, and travels in the pane's URL because the
|
|
33
|
+
# server draws the pane from it again on every cross-filter. It is not part
|
|
34
|
+
# of the frame's id: how tall a pane is does not say which query it is
|
|
35
|
+
# (ADR 047, ADR 029).
|
|
36
|
+
def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, companions: nil, id: nil)
|
|
37
|
+
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height, prominence: prominence, companions: companions.presence,
|
|
31
38
|
where: @janela_fixed_filters.presence }.compact
|
|
32
39
|
base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
|
|
33
40
|
|
|
@@ -39,8 +46,9 @@ module Janela
|
|
|
39
46
|
|
|
40
47
|
# A pane as it was when the snapshot was taken: same shape as janela_pane,
|
|
41
48
|
# not part of the live frame's filter state (ADR 009).
|
|
42
|
-
def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil)
|
|
43
|
-
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit
|
|
49
|
+
def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, companions: nil)
|
|
50
|
+
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height,
|
|
51
|
+
prominence: prominence, companions: companions.presence }.compact
|
|
44
52
|
src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
|
|
45
53
|
|
|
46
54
|
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, snapshot: snapshot),
|
data/app/models/janela/pane.rb
CHANGED
|
@@ -5,6 +5,14 @@ module Janela
|
|
|
5
5
|
# invent a query or reach a model nobody exposed.
|
|
6
6
|
class Pane < ActiveRecord::Base
|
|
7
7
|
SPANS = (1..12).freeze
|
|
8
|
+
|
|
9
|
+
# How tall a bar or line chart is drawn, or nil for what it always was
|
|
10
|
+
# (ADR 047). Five steps rather than pixels: a stored integer selects a class
|
|
11
|
+
# that is already written (ADR 016).
|
|
12
|
+
HEIGHTS = Query::HEIGHTS
|
|
13
|
+
|
|
14
|
+
# How prominent a single value is drawn (ADR 050).
|
|
15
|
+
PROMINENCES = Query::PROMINENCES
|
|
8
16
|
LIMITS = (1..1000).freeze
|
|
9
17
|
# A form cannot offer a thousand options, and these are the row counts a
|
|
10
18
|
# dashboard actually asks for. Any limit inside LIMITS is still valid.
|
|
@@ -30,6 +38,10 @@ module Janela
|
|
|
30
38
|
validates :kind, inclusion: { in: KINDS }
|
|
31
39
|
validates :span, inclusion: { in: SPANS }
|
|
32
40
|
validates :limit, inclusion: { in: LIMITS }, allow_nil: true
|
|
41
|
+
validates :height, inclusion: { in: HEIGHTS }, allow_nil: true
|
|
42
|
+
validates :prominence, inclusion: { in: PROMINENCES }, allow_nil: true
|
|
43
|
+
before_validation :normalise_companions
|
|
44
|
+
validate :companions_are_declared
|
|
33
45
|
validates :measure, presence: true, if: :query?
|
|
34
46
|
validate :declared_by_a_janela_block, if: :query?
|
|
35
47
|
validate :holds_no_query, unless: :query?
|
|
@@ -72,7 +84,7 @@ module Janela
|
|
|
72
84
|
# instead (ADR 018).
|
|
73
85
|
def query(filters: {}, fixed: {}, renderer: self.renderer)
|
|
74
86
|
Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
|
|
75
|
-
renderer: renderer, granularity: granularity, limit: limit, filters: filters, fixed: fixed,
|
|
87
|
+
renderer: renderer, granularity: granularity, limit: limit, height: height, prominence: prominence, companions: companions, filters: filters, fixed: fixed,
|
|
76
88
|
default: frame.default_for(definition.model), title: title)
|
|
77
89
|
end
|
|
78
90
|
|
|
@@ -107,6 +119,25 @@ module Janela
|
|
|
107
119
|
end
|
|
108
120
|
|
|
109
121
|
private
|
|
122
|
+
# A multiple select sends an empty string beside its choices, and an empty
|
|
123
|
+
# selection is nothing rather than an empty list.
|
|
124
|
+
def normalise_companions
|
|
125
|
+
self.companions = Array(companions).map(&:to_s).reject(&:blank?).presence
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The same rules a URL is held to, from the same place, so a row cannot
|
|
129
|
+
# name what a request could not (ADR 051).
|
|
130
|
+
def companions_are_declared
|
|
131
|
+
return if companions.blank? || !query? || measure.blank? || Query::RENDERERS.exclude?(renderer.to_s)
|
|
132
|
+
|
|
133
|
+
Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
|
|
134
|
+
renderer: renderer, companions: companions)
|
|
135
|
+
rescue Janela::BadRequest => error
|
|
136
|
+
errors.add(:companions, error.message)
|
|
137
|
+
rescue Janela::Error
|
|
138
|
+
nil # an unknown model is reported by its own validation
|
|
139
|
+
end
|
|
140
|
+
|
|
110
141
|
def panes_above
|
|
111
142
|
frame.panes.where(position: ...position).order(:position)
|
|
112
143
|
end
|