janela 0.11.0 → 0.12.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +26 -6
  4. data/UPGRADING.md +19 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +6 -1
  6. data/app/assets/javascripts/janela/frame_controller.js +19 -9
  7. data/app/assets/stylesheets/janela.css +23 -1
  8. data/app/controllers/janela/panes_controller.rb +2 -2
  9. data/app/controllers/janela/queries_controller.rb +3 -0
  10. data/app/controllers/janela/snapshot_queries_controller.rb +3 -0
  11. data/app/helpers/janela/frames_helper.rb +12 -4
  12. data/app/models/janela/pane.rb +32 -1
  13. data/app/models/janela/query.rb +116 -4
  14. data/app/views/janela/panes/_form.html.erb +25 -0
  15. data/app/views/janela/queries/_query.html.erb +24 -2
  16. data/config/locales/en.yml +19 -0
  17. data/db/migrate/20260930000001_add_height_to_janela_panes.rb +7 -0
  18. data/db/migrate/20260930000002_add_prominence_to_janela_panes.rb +7 -0
  19. data/db/migrate/20260930000003_add_companions_to_janela_panes.rb +8 -0
  20. data/docs/decisions/024-selecting-more-than-one-value.md +1 -1
  21. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +1 -1
  22. data/docs/decisions/047-a-charts-height-is-one-of-five-steps.md +169 -0
  23. data/docs/decisions/048-a-frame-can-be-told-to-refresh-and-janela-never-decides-when.md +154 -0
  24. data/docs/decisions/049-the-null-group-is-one-more-value-in-a-selection.md +143 -0
  25. data/docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md +124 -0
  26. data/docs/decisions/051-a-table-can-carry-companion-columns.md +160 -0
  27. data/docs/decisions/052-how-the-project-is-run.md +93 -0
  28. data/docs/decisions/INDEX.md +24 -18
  29. data/docs/roadmap.md +12 -27
  30. data/docs/theming.md +4 -0
  31. data/lib/janela/definition.rb +71 -4
  32. data/lib/janela/dimension.rb +7 -0
  33. data/lib/janela/engine.rb +13 -1
  34. data/lib/janela/measure.rb +56 -7
  35. data/lib/janela/version.rb +1 -1
  36. metadata +17 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2079a652eafc71edc8c31227d2c09ef8e795a38da68b8ec6c18de89732a54f97
4
- data.tar.gz: b024b3337cd75f63619ff8cb844a6887e2b4ecb0bf7cb510ceb2fea789537b03
3
+ metadata.gz: 2416bb876bf67b3cda96853cc4cee216fd8c153a0ec4930d8243512be5c8c163
4
+ data.tar.gz: 1b4a35484a9d04d696771376389ab35dc17b76469ce772de54e48ed7017f5cc5
5
5
  SHA512:
6
- metadata.gz: 949892c698668e8a170d5c2dedadeff080f62aee007cf86a89537c62eb304fd75fa479e4d4eb9635e18619794e3003541d90035711e6aa1304771d4f84a1d954
7
- data.tar.gz: e445c3469c68b7f9b5e20d384bbd0589b0ec98f5beeacdbbb48b0f371e0f67771448d42b8240dc859c5fe11e5459d66b57f7652cb2106e56346adb49fd453693
6
+ metadata.gz: 6f20d48d6dfcb473861d0e20b9116e135498d6879a79f9c5720a184b35a66be4189d7e74bc51431d116f4955c73b458af59ff428062e237ee0a2421564436ad0
7
+ data.tar.gz: 5b9c28323e356a9c6833ea25d0c0e3ef8c92225848ef7611fb4c3aeafb95bd75e7c0087d9b3957d819c161fedd1bfacd915b3731382ae78cf6874dca94202645
data/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ 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.12.0] - 2026-10-01
9
+
10
+ ### Added
11
+
12
+ - **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).
13
+
14
+ - **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).
15
+
16
+ - **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).
17
+
18
+ - **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).
19
+
20
+ - **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).
21
+
22
+ ### Changed
23
+
24
+ - `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).
25
+
26
+ ### Fixed
27
+
28
+ - **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).
29
+
8
30
  ## [0.11.0] - 2026-09-30
9
31
 
10
32
  ### Added
@@ -234,6 +256,7 @@ First alpha, installed from GitHub for testing in a single host application.
234
256
  - Only models that declare a `janela` block are addressable over HTTP.
235
257
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
236
258
 
259
+ [0.12.0]: https://github.com/retail-tasker/janela/releases/tag/v0.12.0
237
260
  [0.11.0]: https://github.com/retail-tasker/janela/releases/tag/v0.11.0
238
261
  [0.10.0]: https://github.com/retail-tasker/janela/releases/tag/v0.10.0
239
262
  [0.9.0]: https://github.com/retail-tasker/janela/releases/tag/v0.9.0
data/README.md CHANGED
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It has t
27
27
 
28
28
  ```ruby
29
29
  # Gemfile
30
- gem "janela", "~> 0.11"
30
+ gem "janela", "~> 0.12"
31
31
  ```
32
32
 
33
33
  ```ruby
@@ -55,7 +55,7 @@ The chart controller imports `chart.js`, which is a peer dependency: add `chart.
55
55
 
56
56
  **With importmap-rails:**
57
57
 
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:
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), and declares them precompilable, so it works the same on Propshaft and on Sprockets. Register the controllers:
59
59
 
60
60
  ```js
61
61
  // app/javascript/application.js
@@ -158,6 +158,14 @@ Precision defaults to what the schema already says. Counting rows has no decimal
158
158
 
159
159
  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
160
 
161
+ **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.
162
+
163
+ ```ruby
164
+ measure :expedited_rate, ratio: :expedited # 31.2%
165
+ ```
166
+
167
+ 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).
168
+
161
169
  Then query them:
162
170
 
163
171
  ```ruby
@@ -211,6 +219,12 @@ A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` ke
211
219
 
212
220
  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
221
 
222
+ **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).
223
+
224
+ **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).
225
+
226
+ **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).
227
+
214
228
  **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
229
 
216
230
  ```erb
@@ -349,9 +363,9 @@ A host with no tenancy defines nothing, gets a nil owner, and is correct: nothin
349
363
 
350
364
  ### Filters and clicks
351
365
 
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. Time panes re-scope with the others but are not click sources yet; drill-down is the next decision. 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.
366
+ 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
367
 
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.
368
+ **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
369
 
356
370
  ### Pane URLs
357
371
 
@@ -544,7 +558,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
544
558
 
545
559
  ## Status
546
560
 
547
- **v0.11.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 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, drill-down on time panes, other chart types. [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).
561
+ **v0.12.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 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, drill-down on time panes, other chart types. [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
562
 
549
563
  ## Development
550
564
 
@@ -561,7 +575,13 @@ bin/rails console # console inside the dummy app, engine loaded
561
575
 
562
576
  ## Contributing
563
577
 
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).
578
+ 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](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).
579
+
580
+ ## Support and security
581
+
582
+ 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.
583
+
584
+ To report a vulnerability, use the private form described in [SECURITY.md](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](RELEASING.md).
565
585
 
566
586
  ## License
567
587
 
data/UPGRADING.md CHANGED
@@ -12,6 +12,25 @@ bin/rails janela:doctor
12
12
 
13
13
  It reads your application and lists what still needs changing.
14
14
 
15
+ ## 0.11.0 to 0.12.0
16
+
17
+ One migration step, if you use stored frames.
18
+
19
+ **Take the pane migrations.**
20
+
21
+ A pane can now carry a chart height (ADR 047), a single value a
22
+ prominence (ADR 050) and a table companion columns (ADR 051).
23
+ `janela_panes` gains a nullable `height`, a nullable `prominence` and a
24
+ nullable JSON `companions`:
25
+
26
+ ```bash
27
+ bin/rails janela:install:migrations
28
+ bin/rails db:migrate
29
+ ```
30
+
31
+ Every existing pane keeps all three nil and is drawn exactly as before. If you
32
+ never use stored frames, there is nothing to do.
33
+
15
34
  ## 0.10.0 to 0.11.0
16
35
 
17
36
  No migration. Two things you may see.
@@ -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 filters = { ...this.filtersValue }
85
- const selected = this.valuesFor(filters, key).includes(String(value))
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
- if (key.endsWith("_null")) {
93
- if (!selected) filters[key] = "1"
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:last-child { text-align: right; font-variant-numeric: tabular-nums; }
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; }
@@ -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
  )
@@ -13,6 +13,9 @@ module Janela
13
13
  renderer: params.fetch(:as, "table"),
14
14
  granularity: params[:granularity],
15
15
  limit: params[:limit],
16
+ height: params[:height],
17
+ prominence: params[:prominence],
18
+ companions: params[:companions],
16
19
  snapshot: snapshot
17
20
  )
18
21
 
@@ -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
- def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, id: nil)
30
- query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit,
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 }.compact
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),
@@ -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
@@ -18,7 +18,22 @@ module Janela
18
18
  # categories the same beside a legend that says they differ (ADR 046).
19
19
  SERIES = 8
20
20
 
21
- attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :fixed, :default, :snapshot
21
+ # The steps a chart's height may take (ADR 047). Pane::HEIGHTS is the same
22
+ # range for a stored row, and a test holds the two together.
23
+ HEIGHTS = (1..5).freeze
24
+
25
+ # The steps a single value's prominence may take (ADR 050).
26
+ PROMINENCES = (1..3).freeze
27
+
28
+ # Each companion column is a query of its own, so how many a pane may run
29
+ # is bounded, as what one query may ask for is (ADR 051, ADR 025).
30
+ MAX_COMPANIONS = 3
31
+
32
+ # A column beside a table's label: a measure's formatted numbers, or a
33
+ # dimension's shared fact, by label.
34
+ Companion = Struct.new(:name, :header, :fact, :cells, keyword_init: true)
35
+
36
+ attr_reader :definition, :measure, :dimension, :renderer, :limit, :height, :prominence, :companions, :filters, :fixed, :default, :snapshot
22
37
 
23
38
  # The helper renders the turbo frame and the controller renders its
24
39
  # replacement, so both derive the id the same way from the same parameters.
@@ -28,7 +43,7 @@ module Janela
28
43
  parts.compact.join("_")
29
44
  end
30
45
 
31
- def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
46
+ def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, height: nil, prominence: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
32
47
  @definition = definition
33
48
  @title = title
34
49
  @measure = measure
@@ -42,6 +57,9 @@ module Janela
42
57
  raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
43
58
  @granularity = Dimension.granularity!(granularity) if granularity.present?
44
59
  @limit = definition.limit!(limit) if limit.present?
60
+ @height = height!(height) if height.present?
61
+ @prominence = prominence!(prominence) if prominence.present?
62
+ @companions = companions!(companions)
45
63
  end
46
64
 
47
65
  def model
@@ -82,6 +100,20 @@ module Janela
82
100
  !single_value? && CANVAS.include?(renderer)
83
101
  end
84
102
 
103
+ # A height means a box only where there is a canvas to fill it. A ring, a
104
+ # table and a single value ignore one, so switching a pane between
105
+ # renderers never invalidates it (ADR 047).
106
+ # Only a single value has a headline number to make more or less of. A
107
+ # table, a chart and a ring ignore one, so a pane switched between renderers
108
+ # keeps what it had (ADR 050).
109
+ def prominent?
110
+ single_value? && !prominence.nil?
111
+ end
112
+
113
+ def boxed?
114
+ chart? && !height.nil?
115
+ end
116
+
85
117
  def ring?
86
118
  !single_value? && RINGS.include?(renderer)
87
119
  end
@@ -188,9 +220,30 @@ module Janela
188
220
  # selection, which this pane shows the alternatives to (ADR 040, 043).
189
221
  on = definition.narrow(on || model.all, default) if default.present?
190
222
  on = definition.narrow(on || model.all, fixed) if fixed.present?
191
- return time_result(on) if time?
223
+ primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
224
+ @companion_columns = companions? ? fetch_companions(primary, on) : []
225
+ primary
226
+ end
227
+
228
+ # Only a table draws companion columns, and a stored pane is the record of
229
+ # a moment that holds none (ADR 051).
230
+ def companions?
231
+ renderer == "table" && !single_value? && !frozen? && companions.any?
232
+ end
233
+
234
+ # The columns beside the label, once a result has been read. Kept from the
235
+ # same read as the result, with the same scope and filters, the way a time
236
+ # pane keeps its buckets.
237
+ def companion_columns
238
+ @companion_columns || []
239
+ end
240
+
241
+ def dimension_header
242
+ dimension.to_s.humanize
243
+ end
192
244
 
193
- definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
245
+ def measure_header
246
+ measure.to_s.humanize
194
247
  end
195
248
 
196
249
  # The two filters a click on this label writes, for a time pane: the start
@@ -290,6 +343,65 @@ module Janela
290
343
  }.keys
291
344
  end
292
345
 
346
+ def companions!(value)
347
+ return [] if value.blank?
348
+ raise BadRequest, "companions must be a list of measure and dimension names, got #{value.class}" unless value.is_a?(Array) && value.all? { |each| each.is_a?(String) || each.is_a?(Symbol) }
349
+
350
+ names = value.map(&:to_sym)
351
+ raise BadRequest, "a pane may carry at most #{MAX_COMPANIONS} companions, got #{names.size}" if names.size > MAX_COMPANIONS
352
+ raise BadRequest, "companions must each be named once, got #{names.map(&:inspect).join(', ')}" unless names == names.uniq
353
+
354
+ names.each { |name| companion!(name) }
355
+ end
356
+
357
+ # Only what the model declared, by the name it declared it under: no
358
+ # column, no expression, nothing that becomes SQL (ADR 025, ADR 051).
359
+ def companion!(name)
360
+ declared = definition.measures.keys + definition.dimensions.keys
361
+ raise BadRequest, "#{name.inspect} is not a declared measure or dimension of #{model}. Declared: #{declared.join(', ')}" unless declared.include?(name)
362
+ raise BadRequest, "#{name.inspect} is this pane's own #{name == measure ? 'measure' : 'dimension'}" if name == measure || name == dimension
363
+ return if definition.measures.key?(name)
364
+
365
+ fact = definition.dimensions.fetch(name)
366
+ raise BadRequest, "#{name.inspect} is a time dimension, and a bucket is not a fact about a label" if fact.time?
367
+ raise BadRequest, "#{name.inspect} is a dimension, which a time pane has no shared fact to show" if dimension && definition.dimension!(dimension).time?
368
+ end
369
+
370
+ # Ordering and the limit belong to the primary measure. The companions are
371
+ # fetched for the labels it chose, one grouped query for each measure and
372
+ # one for all the dimensions (ADR 051).
373
+ def fetch_companions(primary, on)
374
+ keys = time? ? nil : primary.keys
375
+ facts = companions.reject { |name| definition.measures.key?(name) }
376
+ shared = facts.any? ? definition.facts(facts, by: dimension, where: applicable_filters, on: on, keys: keys) : {}
377
+
378
+ companions.map do |name|
379
+ if definition.measures.key?(name)
380
+ values = definition.query(name, by: dimension, where: applicable_filters, on: on, granularity: granularity, keys: keys)
381
+ formatted = definition.measure!(name)
382
+ Companion.new(name: name, header: name.to_s.humanize, fact: false,
383
+ cells: primary.keys.to_h { |label| [ label, formatted.format(values[label]) ] })
384
+ else
385
+ Companion.new(name: name, header: name.to_s.humanize, fact: true,
386
+ cells: primary.keys.to_h { |label| [ label, shared.dig(label, name).to_s ] })
387
+ end
388
+ end
389
+ end
390
+
391
+ def prominence!(value)
392
+ step = Integer(value.to_s, exception: false)
393
+ raise BadRequest, "prominence must be a whole number from #{PROMINENCES.first} to #{PROMINENCES.last}, got #{value.inspect}" unless PROMINENCES.cover?(step)
394
+
395
+ step
396
+ end
397
+
398
+ def height!(value)
399
+ step = Integer(value.to_s, exception: false)
400
+ raise BadRequest, "height must be a whole number from #{HEIGHTS.first} to #{HEIGHTS.last}, got #{value.inspect}" unless HEIGHTS.cover?(step)
401
+
402
+ step
403
+ end
404
+
293
405
  def applicable_filters
294
406
  return filters if single_value?
295
407
 
@@ -41,6 +41,31 @@
41
41
  <%= form.select :limit, Janela::Pane::OFFERED_LIMITS, include_blank: t("janela.panes.no_limit") %>
42
42
  </div>
43
43
 
44
+ <%# What may sit beside the label: this model's other measures and its
45
+ categorical dimensions. The pane's own choices are left out when they are
46
+ already made (ADR 051). %>
47
+ <%
48
+ companion_choices = {
49
+ t("janela.companions.measures") => definition.measures.keys.reject { |name| name.to_s == pane.measure }.map { |name| [ name.to_s.humanize, name ] },
50
+ t("janela.companions.dimensions") => definition.dimensions.values.reject { |each| each.time? || each.name.to_s == pane.dimension }.map { |each| [ each.name.to_s.humanize, each.name ] }
51
+ }
52
+ %>
53
+ <div class="janela-field">
54
+ <%= form.label :companions %>
55
+ <%= form.select :companions, companion_choices, {}, multiple: true %>
56
+ <span class="janela-hint"><%= t("janela.companions.hint") %></span>
57
+ </div>
58
+
59
+ <div class="janela-field">
60
+ <%= form.label :height %>
61
+ <%= form.select :height, Janela::Pane::HEIGHTS.map { |step| [ t("janela.heights.#{step}"), step ] }, include_blank: t("janela.heights.automatic") %>
62
+ </div>
63
+
64
+ <div class="janela-field">
65
+ <%= form.label :prominence %>
66
+ <%= form.select :prominence, Janela::Pane::PROMINENCES.map { |step| [ t("janela.prominences.#{step}"), step ] }, include_blank: t("janela.prominences.automatic") %>
67
+ </div>
68
+
44
69
  <div class="janela-field">
45
70
  <%= form.label :span %>
46
71
  <%= form.select :span, Janela::Pane::SPANS.to_a %>