janela 0.14.0 → 0.15.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 +11 -0
- data/README.md +5 -3
- data/UPGRADING.md +25 -0
- data/app/controllers/janela/panes_controller.rb +1 -1
- data/app/controllers/janela/queries_controller.rb +1 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +1 -0
- data/app/helpers/janela/frames_helper.rb +6 -6
- data/app/models/janela/pane.rb +8 -1
- data/app/models/janela/query.rb +12 -7
- data/app/models/janela/snapshot.rb +2 -2
- data/app/views/janela/panes/_form.html.erb +7 -0
- data/config/locales/en.yml +7 -0
- data/db/migrate/20261013000001_add_sort_to_janela_panes.rb +7 -0
- data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md +204 -0
- data/docs/decisions/INDEX.md +7 -6
- data/docs/roadmap.md +8 -4
- data/lib/janela/definition.rb +35 -2
- data/lib/janela/doctor.rb +1 -1
- data/lib/janela/tools.rb +4 -2
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +4 -0
- metadata +8 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b6b7c6d32876fc33b9c40e07278094bc901c548ac355d30f2d4cd2048f41cde
|
|
4
|
+
data.tar.gz: c60ff46e6023319430c63a53ee5bae675fbf98541e6639982b62c5d79cd0b76b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a48f8cd95951b00dfd10bf9d3a4ee9476bf486cd3660618a83f561259e61c61e22289cbce2647fe75b8463d14d25aa710354d760ad549bbf51e25d5ae67cdecb
|
|
7
|
+
data.tar.gz: 92f1c1a0b289f523a81b4a00d6c7c63b50d480e199d0ac42ffb0db34dc98064d4b9b44b01e0dc65d5f485fcbd4e6eb6a9d897401ad197522c66ad7541cbf85a0
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ 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.15.0] - 2026-10-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **A category pane can be sorted by its measure either way, or by its label.** `janela_pane Order, :revenue, by: :customer, limit: 5, sort: :asc` shows the bottom five, and `janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label` draws a chart in the dimension's own order. `sort` takes `desc` (the default, and what every pane did before), `asc`, `label` and `label_desc`, as a select on the pane form and in `Janela::Tools`, and travels in the pane URL as `?sort=asc`. The limit follows the order, so `asc` with a limit is the real smallest rows and not the largest turned round, which the view could not do. A label order is the database sorting the column, so a number is in numeric order, a date in date order and a string in the database's collation. The `(none)` group is last in every order, because SQLite and PostgreSQL disagree about where NULL sorts. A time pane is always in time order and ignores it. Stored panes need a migration; see `UPGRADING.md`. `Janela.sorts` lists the four for a page that offers them as controls (ADR 055, #75, #80).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **Groups tied on the measure now come out by label.** Two groups with the same value used to come out in whatever order the database produced, which with a `limit` decided which of them was shown at all. They are now in label order, ascending, in either direction. A pane with no ties draws exactly what it did. A set `sort` is part of the pane's frame id (`..._top5_asc`), and an unset one leaves every existing id as it was (ADR 055).
|
|
17
|
+
|
|
8
18
|
## [0.14.0] - 2026-10-07
|
|
9
19
|
|
|
10
20
|
### Added
|
|
@@ -289,6 +299,7 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
289
299
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
290
300
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
291
301
|
|
|
302
|
+
[0.15.0]: https://github.com/retail-tasker/janela/releases/tag/v0.15.0
|
|
292
303
|
[0.14.0]: https://github.com/retail-tasker/janela/releases/tag/v0.14.0
|
|
293
304
|
[0.13.0]: https://github.com/retail-tasker/janela/releases/tag/v0.13.0
|
|
294
305
|
[0.12.0]: https://github.com/retail-tasker/janela/releases/tag/v0.12.0
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It is a
|
|
|
27
27
|
|
|
28
28
|
```ruby
|
|
29
29
|
# Gemfile
|
|
30
|
-
gem "janela", "~> 0.
|
|
30
|
+
gem "janela", "~> 0.15"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
@@ -224,7 +224,7 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
|
|
|
224
224
|
<% end %>
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, `:doughnut` or `:pie` for a part-to-whole split, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
|
|
227
|
+
A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars, and `sort:` changes which ten (see below). `as:` is `:table` by default, `:bar` for a Chart.js bar chart, `:doughnut` or `:pie` for a part-to-whole split, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
|
|
228
228
|
|
|
229
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).
|
|
230
230
|
|
|
@@ -232,6 +232,8 @@ A doughnut or a pie is drawn on the server as SVG with a legend of buttons besid
|
|
|
232
232
|
|
|
233
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
234
|
|
|
235
|
+
**Sorting.** A breakdown by a category is largest first by default. `sort: :asc` puts the smallest first, so `limit: 5` is the bottom five, and `sort: :label` and `sort: :label_desc` order by the dimension's own value, which suits a label whose text is already the natural order, such as a period code (`janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label`). The limit follows the order. A number or a date column sorts as itself, and text sorts by your database's collation, which is not the same everywhere. The `(none)` group is always last. Groups that tie on the measure come out by label. It is a select on the pane form, `sort:` in `Janela::Tools`, and `?sort=` in the pane URL. A time series is always in time order and ignores it (ADR 055).
|
|
236
|
+
|
|
235
237
|
**Value labels.** A bar chart shows a value only in its tooltip unless the pane asks for it on the bar: `value_labels: true` (`janela_pane Order, :expedited_rate, by: :channel, as: :bar, value_labels: true`), or the checkbox on the pane form. The label is the same string the tooltip shows, the measure's own format, drawn beyond the end of the bar: above a positive one and below a negative. It is all or none: if any label is wider than its bar's share of the chart, none is drawn, rather than some bars carrying a number and others not. Make the chart wider or `limit:` the bars. With none set nothing changes, and a line, a ring, a table and a single value ignore it (ADR 054).
|
|
236
238
|
|
|
237
239
|
**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).
|
|
@@ -563,7 +565,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
563
565
|
|
|
564
566
|
## Status
|
|
565
567
|
|
|
566
|
-
**v0.
|
|
568
|
+
**v0.15.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, bar charts that read in their measure's format and can show each bar's value, category panes that can be ordered either way by their measure or by their label, 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).
|
|
567
569
|
|
|
568
570
|
## Development
|
|
569
571
|
|
data/UPGRADING.md
CHANGED
|
@@ -12,6 +12,31 @@ bin/rails janela:doctor
|
|
|
12
12
|
|
|
13
13
|
It reads your application and lists what still needs changing.
|
|
14
14
|
|
|
15
|
+
## 0.14.0 to 0.15.0
|
|
16
|
+
|
|
17
|
+
One migration step, if you use stored frames.
|
|
18
|
+
|
|
19
|
+
**Take the pane migration.**
|
|
20
|
+
|
|
21
|
+
A category pane can now be sorted (ADR 055). `janela_panes` gains a nullable
|
|
22
|
+
string `sort`:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bin/rails janela:install:migrations
|
|
26
|
+
bin/rails db:migrate
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Every existing pane keeps it nil and is ordered exactly as before, largest
|
|
30
|
+
first. If you never use stored frames, there is nothing to do.
|
|
31
|
+
`bin/rails janela:doctor` names the column if a stored-frames host skips the
|
|
32
|
+
step.
|
|
33
|
+
|
|
34
|
+
**If you carry a patch that orders panes,** it can go: `sort: :asc` and
|
|
35
|
+
`sort: :label` are the options it was written to add. Groups that tie on the
|
|
36
|
+
measure now come out in label order where they used to come out in the
|
|
37
|
+
database's, so a pane that was relying on that has nothing to change and is
|
|
38
|
+
now the same every time.
|
|
39
|
+
|
|
15
40
|
## 0.13.0 to 0.14.0
|
|
16
41
|
|
|
17
42
|
One migration step, if you use stored frames.
|
|
@@ -76,7 +76,7 @@ module Janela
|
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def pane_params
|
|
79
|
-
params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :height, :prominence, :value_labels, :span, :title,
|
|
79
|
+
params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :sort, :height, :prominence, :value_labels, :span, :title,
|
|
80
80
|
:heading, :body, :link, :partial, { companions: [] } ])
|
|
81
81
|
end
|
|
82
82
|
|
|
@@ -34,13 +34,13 @@ module Janela
|
|
|
34
34
|
# server draws the pane from it again on every cross-filter. It is not part
|
|
35
35
|
# of the frame's id: how tall a pane is does not say which query it is
|
|
36
36
|
# (ADR 047, ADR 029).
|
|
37
|
-
def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, id: nil)
|
|
38
|
-
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height, prominence: prominence,
|
|
37
|
+
def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, id: nil)
|
|
38
|
+
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence,
|
|
39
39
|
value_labels: (1 if value_labels), companions: companions.presence,
|
|
40
40
|
where: @janela_fixed_filters.presence }.compact
|
|
41
41
|
base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
|
|
42
42
|
|
|
43
|
-
turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
|
|
43
|
+
turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort),
|
|
44
44
|
src: janela_with_page_filters(base),
|
|
45
45
|
loading: :lazy,
|
|
46
46
|
data: { janela__frame_target: "pane", janela_src: base }
|
|
@@ -48,12 +48,12 @@ module Janela
|
|
|
48
48
|
|
|
49
49
|
# A pane as it was when the snapshot was taken: same shape as janela_pane,
|
|
50
50
|
# not part of the live frame's filter state (ADR 009).
|
|
51
|
-
def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil)
|
|
52
|
-
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height,
|
|
51
|
+
def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil)
|
|
52
|
+
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, sort: sort, height: height,
|
|
53
53
|
prominence: prominence, value_labels: (1 if value_labels), companions: companions.presence }.compact
|
|
54
54
|
src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
|
|
55
55
|
|
|
56
|
-
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, snapshot: snapshot),
|
|
56
|
+
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort, snapshot: snapshot),
|
|
57
57
|
src: src, loading: :lazy
|
|
58
58
|
end
|
|
59
59
|
|
data/app/models/janela/pane.rb
CHANGED
|
@@ -14,6 +14,10 @@ module Janela
|
|
|
14
14
|
# How prominent a single value is drawn (ADR 050).
|
|
15
15
|
PROMINENCES = Query::PROMINENCES
|
|
16
16
|
LIMITS = (1..1000).freeze
|
|
17
|
+
|
|
18
|
+
# How a category pane is ordered (ADR 055). Nil is the default, largest
|
|
19
|
+
# first by the measure.
|
|
20
|
+
SORTS = Definition::SORTS
|
|
17
21
|
# A form cannot offer a thousand options, and these are the row counts a
|
|
18
22
|
# dashboard actually asks for. Any limit inside LIMITS is still valid.
|
|
19
23
|
OFFERED_LIMITS = [ 5, 10, 20, 50, 100 ].freeze
|
|
@@ -38,8 +42,11 @@ module Janela
|
|
|
38
42
|
validates :kind, inclusion: { in: KINDS }
|
|
39
43
|
validates :span, inclusion: { in: SPANS }
|
|
40
44
|
validates :limit, inclusion: { in: LIMITS }, allow_nil: true
|
|
45
|
+
validates :sort, inclusion: { in: SORTS }, allow_nil: true
|
|
41
46
|
validates :height, inclusion: { in: HEIGHTS }, allow_nil: true
|
|
42
47
|
validates :prominence, inclusion: { in: PROMINENCES }, allow_nil: true
|
|
48
|
+
# A form sends an empty string for "Largest first", which is unset.
|
|
49
|
+
normalizes :sort, with: ->(value) { value.presence }
|
|
43
50
|
before_validation :normalise_companions
|
|
44
51
|
validate :companions_are_declared
|
|
45
52
|
validates :measure, presence: true, if: :query?
|
|
@@ -84,7 +91,7 @@ module Janela
|
|
|
84
91
|
# instead (ADR 018).
|
|
85
92
|
def query(filters: {}, fixed: {}, renderer: self.renderer)
|
|
86
93
|
Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
|
|
87
|
-
renderer: renderer, granularity: granularity, limit: limit, height: height, prominence: prominence, value_labels: value_labels, companions: companions, filters: filters, fixed: fixed,
|
|
94
|
+
renderer: renderer, granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence, value_labels: value_labels, companions: companions, filters: filters, fixed: fixed,
|
|
88
95
|
default: frame.default_for(definition.model), title: title)
|
|
89
96
|
end
|
|
90
97
|
|
data/app/models/janela/query.rb
CHANGED
|
@@ -33,17 +33,18 @@ module Janela
|
|
|
33
33
|
# dimension's shared fact, by label.
|
|
34
34
|
Companion = Struct.new(:name, :header, :fact, :cells, keyword_init: true)
|
|
35
35
|
|
|
36
|
-
attr_reader :definition, :measure, :dimension, :renderer, :limit, :height, :prominence, :value_labels, :companions, :filters, :fixed, :default, :snapshot
|
|
36
|
+
attr_reader :definition, :measure, :dimension, :renderer, :limit, :sort, :height, :prominence, :value_labels, :companions, :filters, :fixed, :default, :snapshot
|
|
37
37
|
|
|
38
38
|
# The helper renders the turbo frame and the controller renders its
|
|
39
39
|
# replacement, so both derive the id the same way from the same parameters.
|
|
40
|
-
def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, snapshot: nil)
|
|
40
|
+
def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, snapshot: nil)
|
|
41
41
|
parts = [ "janela", ("snapshot_#{snapshot.id}" if snapshot), model.model_name.route_key, measure, by,
|
|
42
|
-
(granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit)
|
|
42
|
+
(granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit),
|
|
43
|
+
(sort if by && sort.present? && sort.to_s != "desc") ]
|
|
43
44
|
parts.compact.join("_")
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
|
|
47
|
+
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
|
|
47
48
|
@definition = definition
|
|
48
49
|
@title = title
|
|
49
50
|
@measure = measure
|
|
@@ -57,6 +58,10 @@ module Janela
|
|
|
57
58
|
raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
|
|
58
59
|
@granularity = Dimension.granularity!(granularity) if granularity.present?
|
|
59
60
|
@limit = definition.limit!(limit) if limit.present?
|
|
61
|
+
# Unset and desc are the same pane, and stay the same id and the same
|
|
62
|
+
# snapshot key (ADR 055).
|
|
63
|
+
chosen = definition.sort!(sort)
|
|
64
|
+
@sort = chosen unless chosen == "desc"
|
|
60
65
|
@height = height!(height) if height.present?
|
|
61
66
|
@prominence = prominence!(prominence) if prominence.present?
|
|
62
67
|
@value_labels = value_labels!(value_labels)
|
|
@@ -206,7 +211,7 @@ module Janela
|
|
|
206
211
|
|
|
207
212
|
def turbo_frame_id
|
|
208
213
|
self.class.turbo_frame_id(model: model, measure: measure, by: dimension, as: renderer,
|
|
209
|
-
granularity: @granularity, limit: limit, snapshot: snapshot)
|
|
214
|
+
granularity: @granularity, limit: limit, sort: sort, snapshot: snapshot)
|
|
210
215
|
end
|
|
211
216
|
|
|
212
217
|
# A pane row may carry its own title, which is the first analyst authored
|
|
@@ -226,7 +231,7 @@ module Janela
|
|
|
226
231
|
# What identifies this pane's data inside a snapshot.
|
|
227
232
|
def lookup_key
|
|
228
233
|
{ "model" => model.model_name.route_key, "measure" => measure.to_s, "dimension" => dimension&.to_s,
|
|
229
|
-
"granularity" => granularity&.to_s, "limit" => limit }
|
|
234
|
+
"granularity" => granularity&.to_s, "limit" => limit }.tap { |key| key["sort"] = sort if sort }
|
|
230
235
|
end
|
|
231
236
|
|
|
232
237
|
def result(on: nil)
|
|
@@ -238,7 +243,7 @@ module Janela
|
|
|
238
243
|
# selection, which this pane shows the alternatives to (ADR 040, 043).
|
|
239
244
|
on = definition.narrow(on || model.all, default) if default.present?
|
|
240
245
|
on = definition.narrow(on || model.all, fixed) if fixed.present?
|
|
241
|
-
primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
|
|
246
|
+
primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit, sort: sort)
|
|
242
247
|
@companion_columns = companions? ? fetch_companions(primary, on) : []
|
|
243
248
|
primary
|
|
244
249
|
end
|
|
@@ -40,9 +40,9 @@ module Janela
|
|
|
40
40
|
@panes = []
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def pane(model, measure, by: nil, granularity: nil, limit: nil, on: nil)
|
|
43
|
+
def pane(model, measure, by: nil, granularity: nil, limit: nil, sort: nil, on: nil)
|
|
44
44
|
query = Query.new(definition: model.janela, measure: measure, dimension: by,
|
|
45
|
-
granularity: granularity, limit: limit, filters: filters)
|
|
45
|
+
granularity: granularity, limit: limit, sort: sort, filters: filters)
|
|
46
46
|
panes << query.lookup_key.merge("result" => plain(query.result(on: on)))
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
<%= form.select :limit, Janela::Pane::OFFERED_LIMITS, include_blank: t("janela.panes.no_limit") %>
|
|
42
42
|
</div>
|
|
43
43
|
|
|
44
|
+
<%# Blank is largest first, so desc is not offered a second time. %>
|
|
45
|
+
<div class="janela-field">
|
|
46
|
+
<%= form.label :sort %>
|
|
47
|
+
<%= form.select :sort, (Janela.sorts - %w[desc]).map { |value| [ t("janela.sorts.#{value}"), value ] }, include_blank: t("janela.sorts.desc") %>
|
|
48
|
+
<span class="janela-hint"><%= t("janela.sorts.hint") %></span>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
44
51
|
<%# What may sit beside the label: this model's other measures and its
|
|
45
52
|
categorical dimensions. The pane's own choices are left out when they are
|
|
46
53
|
already made (ADR 051). %>
|
data/config/locales/en.yml
CHANGED
|
@@ -19,6 +19,7 @@ en:
|
|
|
19
19
|
renderer: Renderer
|
|
20
20
|
granularity: Granularity
|
|
21
21
|
limit: Rows
|
|
22
|
+
sort: Order
|
|
22
23
|
span: Width
|
|
23
24
|
height: Height
|
|
24
25
|
prominence: Prominence
|
|
@@ -77,6 +78,12 @@ en:
|
|
|
77
78
|
hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
|
|
78
79
|
value_labels:
|
|
79
80
|
hint: Draws the value at the end of each bar of a bar chart, if every one fits. Other charts ignore it.
|
|
81
|
+
sorts:
|
|
82
|
+
desc: Largest first
|
|
83
|
+
asc: Smallest first
|
|
84
|
+
label: By label, A to Z
|
|
85
|
+
label_desc: By label, Z to A
|
|
86
|
+
hint: Applies to a breakdown by a category. A time series is always in time order.
|
|
80
87
|
prominences:
|
|
81
88
|
automatic: Automatic
|
|
82
89
|
"1": Footnote
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddSortToJanelaPanes < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# How a category pane is ordered (ADR 055): desc, asc, label or label_desc.
|
|
4
|
+
# Null is what every existing pane is: unset, and ordered exactly as before.
|
|
5
|
+
add_column :janela_panes, :sort, :string
|
|
6
|
+
end
|
|
7
|
+
end
|
data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-10-13
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 005, ADR 006, ADR 007, ADR 020, ADR 037, ADR 046, ADR 047, ADR 049, ADR 050, ADR 053, ADR 054
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding a sort direction, or sorting a pane by something other than its measure
|
|
7
|
+
- asking for the bottom N, the lowest values, or a chart in label order
|
|
8
|
+
- a dimension whose labels are the natural order, such as a period code
|
|
9
|
+
- adding a parameter to the pane URL, or a column to janela_panes
|
|
10
|
+
- deciding where the NULL group sits in a pane's order
|
|
11
|
+
Topics: ordering, limit, panes, urls, query-layer, public-api, frames
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 055: A Category Pane Can Be Sorted by Its Measure Either Way, or by Its Label
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
ADR 007 ordered every category pane by its measure, largest first, with no
|
|
19
|
+
way to turn that off, and named what it left for later: "an 'other' bucket
|
|
20
|
+
that sums what the limit cut off, ascending order, sorting by label, and
|
|
21
|
+
paging. Each is plausible; none is needed by the first host." Two hosts
|
|
22
|
+
have now needed one each.
|
|
23
|
+
|
|
24
|
+
#75: a pane with a `limit` shows the top N and cannot show the bottom N, the
|
|
25
|
+
lowest scores rather than the highest. Reversing the result in the view
|
|
26
|
+
does not help, because the limit is applied in SQL before the host sees any
|
|
27
|
+
rows, so a reversed top ten is still the top ten. The host that raised it
|
|
28
|
+
carries a patch that gives each pane an ascending or descending order, and
|
|
29
|
+
that patch broke across releases because it copied method bodies.
|
|
30
|
+
|
|
31
|
+
#80: a chart of a measure by a dimension whose labels are the natural order,
|
|
32
|
+
such as `202603 March Grocery Independents`, where the leading `YYYYMM` makes
|
|
33
|
+
alphabetical order chronological, comes out largest first and not oldest
|
|
34
|
+
first, so a trend is unreadable. The host worked round it with a database
|
|
35
|
+
column that holds the month as a time dimension, because a time dimension is
|
|
36
|
+
the one exemption ADR 007 makes. That costs a column, and it merges any two
|
|
37
|
+
cycles that fall in the same month.
|
|
38
|
+
|
|
39
|
+
These are one decision. Both are "which end of which ordering does the pane
|
|
40
|
+
start from", they share every open question below, and building either alone
|
|
41
|
+
risks making the other's shape wrong.
|
|
42
|
+
|
|
43
|
+
### What was measured
|
|
44
|
+
|
|
45
|
+
Against the demo, on SQLite, before deciding.
|
|
46
|
+
|
|
47
|
+
**The ordering is one line.** `Definition#query` groups by the dimension and
|
|
48
|
+
orders by `measure.order_by` descending, then limits. Reversing the
|
|
49
|
+
direction and ordering by the dimension's own column both work with a
|
|
50
|
+
change to that line, and the limit follows the order: ascending by measure
|
|
51
|
+
with a limit of two returned the two smallest groups.
|
|
52
|
+
|
|
53
|
+
**A database sorts a column by the column's type.** Ordering in SQL puts a
|
|
54
|
+
numeric column in numeric order and a date column in date order, and sorts a
|
|
55
|
+
text column as text. A text label such as `202603 ...` sorts correctly
|
|
56
|
+
because its prefix is fixed width, and a text column holding `10` and `9`
|
|
57
|
+
sorts `10` before `9`, as any text sort does. The question #80 left open,
|
|
58
|
+
whether label sorting compares as text or follows the column's type, is
|
|
59
|
+
answered by letting the database do it: it follows the column's type,
|
|
60
|
+
because the column is what is being sorted.
|
|
61
|
+
|
|
62
|
+
**Text order is the database's collation, and it differs.** On SQLite
|
|
63
|
+
`B` sorts before `a`. Other databases sort case insensitively by default.
|
|
64
|
+
Janela does not read the collation and does not try to make them agree.
|
|
65
|
+
|
|
66
|
+
**The NULL group is placed differently by different databases.** SQLite sorts
|
|
67
|
+
NULL first in an ascending order. PostgreSQL, from its documentation and
|
|
68
|
+
not measured here, sorts it last. Left alone, a label ordered pane would
|
|
69
|
+
show `(none)` at the top on one host and at the bottom on another.
|
|
70
|
+
`ORDER BY column IS NULL, column` puts it last everywhere, and measured on
|
|
71
|
+
SQLite it did: `10, 202603 x, 9, B, a, b, (none)`.
|
|
72
|
+
|
|
73
|
+
**Ties have no order today.** Two groups with the same measure come out in
|
|
74
|
+
whatever order the database produces, and with a limit the choice between
|
|
75
|
+
them decides which one is shown at all. That is harmless for a top ten of
|
|
76
|
+
distinct sums and arbitrary for a bottom five of counts, where ties are the
|
|
77
|
+
norm.
|
|
78
|
+
|
|
79
|
+
### What was considered
|
|
80
|
+
|
|
81
|
+
**Order on the measure.** ADR 020 put formatting there, and the reasoning
|
|
82
|
+
does not carry over. Formatting is a fact about the number, true wherever it
|
|
83
|
+
is shown. Order is a choice about one view of it: the same revenue measure is
|
|
84
|
+
sensibly largest first in one pane, smallest first in another and in label
|
|
85
|
+
order in a third on the same page. A measure that carries an order would
|
|
86
|
+
force two measures for one number, which is the workaround ADR 002 offers
|
|
87
|
+
for variation and the wrong one here. The limit is already on the pane for
|
|
88
|
+
the same reason (ADR 007), and order and limit are read together.
|
|
89
|
+
|
|
90
|
+
**Two settings, a field and a direction** (`sort: :label, dir: :desc`). Two
|
|
91
|
+
columns and two URL parameters to say one of four things, with combinations
|
|
92
|
+
that mean nothing for a time pane. One setting with four values is the
|
|
93
|
+
vocabulary `height` and `prominence` already speak (ADR 047, ADR 050).
|
|
94
|
+
|
|
95
|
+
**Sorting in Ruby after the fetch.** Cannot work. The limit is applied in
|
|
96
|
+
SQL, so a Ruby sort of ten rows sorts the wrong ten.
|
|
97
|
+
|
|
98
|
+
**A natural or numeric aware text sort.** Not portable across databases and
|
|
99
|
+
a second query language to document. A host whose labels need it can sort by
|
|
100
|
+
a column that holds the sortable form, which is what the one host already
|
|
101
|
+
does, and which the label order now makes unnecessary for the common case.
|
|
102
|
+
|
|
103
|
+
**An "other" bucket and paging.** Still not needed by anyone. Left where
|
|
104
|
+
ADR 007 left them.
|
|
105
|
+
|
|
106
|
+
## Decision
|
|
107
|
+
|
|
108
|
+
**A category pane may carry a `sort`, one of four values, and unset is
|
|
109
|
+
exactly what a pane does today.**
|
|
110
|
+
|
|
111
|
+
| sort | order | reads as |
|
|
112
|
+
| --- | --- | --- |
|
|
113
|
+
| `desc` (default) | measure, largest first | top N |
|
|
114
|
+
| `asc` | measure, smallest first | bottom N |
|
|
115
|
+
| `label` | the dimension's own value, ascending | alphabetical, oldest first |
|
|
116
|
+
| `label_desc` | the dimension's own value, descending | newest first |
|
|
117
|
+
|
|
118
|
+
```erb
|
|
119
|
+
<%= janela_pane Order, :revenue, by: :customer, limit: 5, sort: :asc %>
|
|
120
|
+
<%= janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label %>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**It is the pane's, in the three places the pane's other options are.**
|
|
124
|
+
|
|
125
|
+
- `Janela::Pane#sort`, a nullable string column validated to the four
|
|
126
|
+
values and offered as a select on the pane form (blank is "Largest
|
|
127
|
+
first").
|
|
128
|
+
- `janela_pane` and `janela_snapshot_pane` take `sort:`.
|
|
129
|
+
- The pane URL carries it as `?sort=asc`, so the server-rendered content
|
|
130
|
+
keeps it on every cross-filter and lazy load, the same reasoning as
|
|
131
|
+
`height` (ADR 047).
|
|
132
|
+
|
|
133
|
+
The column is named `sort` and not `order`, which is a reserved word in SQL
|
|
134
|
+
and the name of a relation method, and not `order_by`, which already means
|
|
135
|
+
the SQL expression on a measure.
|
|
136
|
+
|
|
137
|
+
**The database does the work, and the limit comes after.** For `label` and
|
|
138
|
+
`label_desc` the query orders by the dimension's column, so a number, a date
|
|
139
|
+
and a string each sort as themselves, and the limit then cuts that order:
|
|
140
|
+
`sort: :label, limit: 10` is the first ten labels. For `asc` it orders by the
|
|
141
|
+
measure ascending, so `limit: 5` is the bottom five.
|
|
142
|
+
|
|
143
|
+
**The `(none)` group is always last,** in all four orders, by ordering on
|
|
144
|
+
`column IS NULL` first. It is a group like any other for the measure orders,
|
|
145
|
+
and where its measure falls it falls. For the label orders it has no
|
|
146
|
+
position in the label's own order, so it goes to the end in a way that is
|
|
147
|
+
the same on every database.
|
|
148
|
+
|
|
149
|
+
**Ties are broken by the label, ascending,** in every order, so the same
|
|
150
|
+
data always draws the same pane and a limit between tied groups always
|
|
151
|
+
shows the same one. Unset panes were arbitrary in ties and are now fixed,
|
|
152
|
+
which changes nothing anyone could have relied on.
|
|
153
|
+
|
|
154
|
+
**A time pane ignores `sort`.** A time series is ordered by time and shows
|
|
155
|
+
its whole range (ADR 007), and that stands. A `sort` on one is ignored and
|
|
156
|
+
not an error, as a height is on a ring, so a row whose dimension is changed
|
|
157
|
+
to a time dimension is not made invalid and keeps its value if it is changed
|
|
158
|
+
back. Newest first on a time pane is a different question, left open below.
|
|
159
|
+
|
|
160
|
+
**A ring and a table take it unchanged.** Both draw the result in order: a
|
|
161
|
+
ring's slices and its legend follow it, a table's rows follow it. Nothing
|
|
162
|
+
special is built for either. A ring of the bottom five by `asc` is the caller's
|
|
163
|
+
choice, as a ring of the top five already is, and ADR 046's remark that a
|
|
164
|
+
ring suits few slices stands.
|
|
165
|
+
|
|
166
|
+
**It is part of the pane's identity when set,** the way `limit` is (ADR 007),
|
|
167
|
+
because "top five" and "bottom five" of one breakdown are two different
|
|
168
|
+
answers and may share a page. It is added to `Query.turbo_frame_id` only when
|
|
169
|
+
it is not the default, so every existing frame id is unchanged. This differs
|
|
170
|
+
from `height`, which is drawing and not identity (ADR 047).
|
|
171
|
+
|
|
172
|
+
## Consequences
|
|
173
|
+
|
|
174
|
+
- **A migration for stored frames.** `janela_panes` gains a nullable `sort`,
|
|
175
|
+
additive, so every existing pane is unset and unchanged. A host that uses
|
|
176
|
+
stored frames runs `bin/rails janela:install:migrations` and `db:migrate`,
|
|
177
|
+
written in `UPGRADING.md` and the post install message (ADR 015). The
|
|
178
|
+
doctor's `unmigrated-columns` check (ADR 035) is taught the column.
|
|
179
|
+
- **`Janela::Tools` has to learn the attribute,** in the same change as the
|
|
180
|
+
column, or an agent could arrange every part of a pane except how it is
|
|
181
|
+
ordered (ADR 053). Its vocabulary names the four values.
|
|
182
|
+
- **The pane URL grammar grows by one optional parameter,** which ADR 037
|
|
183
|
+
counts as public surface and is why this belongs before 1.0. ADR 005's
|
|
184
|
+
naming table gains a row: *bottom N* is `?sort=asc&limit=N`.
|
|
185
|
+
- **Snapshots record it.** A snapshot's lookup key includes the sort, as it
|
|
186
|
+
includes the limit, so a frozen "bottom five" is not found for a request
|
|
187
|
+
for "top five" (ADR 009).
|
|
188
|
+
- **Text labels sort by the host database's collation.** Documented, not
|
|
189
|
+
smoothed over. A host that needs a different text order sorts on a column
|
|
190
|
+
that holds it.
|
|
191
|
+
- Companion columns follow the primary measure's order and limit, as they do
|
|
192
|
+
today (ADR 051).
|
|
193
|
+
- The demo's playground gets a control for it, shown only for a category
|
|
194
|
+
pane, with the declaration rewriting itself as for the others (ADR 027).
|
|
195
|
+
- A pane ordered by label no longer puts the largest value first, so a chart
|
|
196
|
+
in label order can bury the biggest bar. That is the analyst's choice and
|
|
197
|
+
the reason for asking.
|
|
198
|
+
- Not decided here: newest first on a time pane; an "other" bucket; paging;
|
|
199
|
+
a host's own ordering by an arbitrary column. Each starts from this
|
|
200
|
+
table.
|
|
201
|
+
- What would change this decision: a need for an ordering by something that
|
|
202
|
+
is neither the measure nor the label, such as a second measure, which is an
|
|
203
|
+
argument for companion columns driving an order (ADR 051) and not for more
|
|
204
|
+
values here; or a database where `column IS NULL` is not portable.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -22,20 +22,20 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
22
22
|
|-------|------|
|
|
23
23
|
| **Vision, scope, forkability** | 001, 010, 012, 037, 052 |
|
|
24
24
|
| **Open-source & host-decoupling** | 001, 022, 036, 041, 052 |
|
|
25
|
-
| **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044, 049, 051 |
|
|
25
|
+
| **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044, 049, 051, 055 |
|
|
26
26
|
| **Dependencies** | 002, 003, 004, 006, 017, 025, 053 |
|
|
27
27
|
| **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035, 039, 040, 048, 053 |
|
|
28
28
|
| **Performance & storage** | 007, 017, 025, 048, 051 |
|
|
29
|
-
| **Ordering & formatting** | 007, 020, 038, 054 |
|
|
29
|
+
| **Ordering & formatting** | 007, 020, 038, 054, 055 |
|
|
30
30
|
| **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043, 044, 045, 048, 049 |
|
|
31
31
|
| **Layouts & views** | 011, 012, 016, 018, 020, 027, 039, 047, 050 |
|
|
32
32
|
| **CSS & styling** | 016, 018, 023, 026, 027, 036, 042, 046, 047, 050 |
|
|
33
|
-
| **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044, 047, 048, 050, 051, 053, 054 |
|
|
33
|
+
| **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044, 047, 048, 050, 051, 053, 054, 055 |
|
|
34
34
|
| **Naming rule** | 014, 023, 036 |
|
|
35
35
|
| **JavaScript delivery & charts** | 004, 006, 026, 042, 046, 047, 054 |
|
|
36
36
|
| **Time dimensions** | 006, 025, 045 |
|
|
37
|
-
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045, 047, 050, 051, 054 |
|
|
38
|
-
| **Snapshots & publishing** | 009, 020, 028, 033, 034, 051 |
|
|
37
|
+
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045, 047, 050, 051, 054, 055 |
|
|
38
|
+
| **Snapshots & publishing** | 009, 020, 028, 033, 034, 051, 055 |
|
|
39
39
|
| **AI agents & guidance** | 010, 015, 021, 053 |
|
|
40
40
|
| **The doctor & checks** | 021, 025, 032, 033, 035 |
|
|
41
41
|
| **Releases & upgrades** | 015, 021, 032, 034, 035, 036, 037, 042, 052 |
|
|
@@ -102,7 +102,8 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
102
102
|
| 052 | Two People Cut Releases, Reports Go Private, and Support Is Best Effort | 2026-09-30 | Accepted |
|
|
103
103
|
| 053 | An Agent Reaches Janela Through Tools the Host Scopes, and Janela Registers None | 2026-10-02 | Accepted |
|
|
104
104
|
| 054 | A Chart Reads Numbers the Way Its Measure Formats Them | 2026-10-06 | Accepted |
|
|
105
|
+
| 055 | A Category Pane Can Be Sorted by Its Measure Either Way, or by Its Label | 2026-10-13 | Accepted |
|
|
105
106
|
|
|
106
107
|
## Next number
|
|
107
108
|
|
|
108
|
-
Next ADR:
|
|
109
|
+
Next ADR: 056
|
data/docs/roadmap.md
CHANGED
|
@@ -134,7 +134,8 @@ change in any release, and every change of that kind carries an entry in
|
|
|
134
134
|
**A pane can be read.** Janela draws tables, bars, lines, doughnuts and
|
|
135
135
|
pies. A table row can carry context beside its label, a single number says
|
|
136
136
|
how prominent it is, a chart has a height that suits the page, its axis reads
|
|
137
|
-
in the measure's own format,
|
|
137
|
+
in the measure's own format, a bar can show its value, and a category pane can
|
|
138
|
+
be put in the order that reads. Those
|
|
138
139
|
were not extra features. They were the 5% not finished, and most of them
|
|
139
140
|
were found by people installing the gem rather than reading it.
|
|
140
141
|
|
|
@@ -151,9 +152,12 @@ height, a ratio measure, single value prominence, companion columns, Sprockets
|
|
|
151
152
|
hosts, and how the project is run. 0.13.0 added tools an agent can use
|
|
152
153
|
(ADR 053). 0.14.0 followed: a chart's axis reads in the measure's own format,
|
|
153
154
|
so a ratio is a percentage down the side as well as in the tooltip, and a bar
|
|
154
|
-
chart can show each bar's value (ADR 054).
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
chart can show each bar's value (ADR 054). 0.15.0 followed that: a category
|
|
156
|
+
pane can be sorted by its measure either way or by its label, so a bottom five
|
|
157
|
+
and a chart in date order can be drawn (ADR 055). All three came from a real
|
|
158
|
+
install, and the last two add a pane option and a URL parameter, so the soak
|
|
159
|
+
starts again from 0.15.0. What 1.0 waits
|
|
160
|
+
on now is a week of real installs of 0.15.0 ([#71](https://github.com/retail-tasker/janela/issues/71)),
|
|
157
161
|
since much of that surface was added in the last days and has only met this
|
|
158
162
|
repository. When nothing surprising comes back, the surface is frozen and 1.0
|
|
159
163
|
is tagged. The agent tools are the one new piece of surface: the read tools are
|
data/lib/janela/definition.rb
CHANGED
|
@@ -4,6 +4,11 @@ module Janela
|
|
|
4
4
|
# applied when a host asks for none, and as the bound on one filter's values.
|
|
5
5
|
MAXIMUM = 1000
|
|
6
6
|
|
|
7
|
+
# How a category pane is ordered (ADR 055): by its measure, largest or
|
|
8
|
+
# smallest first, or by the dimension's own value, ascending or descending.
|
|
9
|
+
# The first is what a pane did before there was a choice.
|
|
10
|
+
SORTS = %w[desc asc label label_desc].freeze
|
|
11
|
+
|
|
7
12
|
attr_reader :model, :measures, :dimensions
|
|
8
13
|
|
|
9
14
|
# The class whose janela block this is, which a subclass shares with the
|
|
@@ -44,8 +49,9 @@ module Janela
|
|
|
44
49
|
# keys: narrows a categorical grouping to the labels a companion column is
|
|
45
50
|
# being fetched for (ADR 051), so it is the primary's rows and not the
|
|
46
51
|
# companion's own top 1000.
|
|
47
|
-
def query(measure_name, by: nil, where: {}, on: nil, granularity: nil, limit: nil, keys: nil)
|
|
52
|
+
def query(measure_name, by: nil, where: {}, on: nil, granularity: nil, limit: nil, keys: nil, sort: nil)
|
|
48
53
|
measure = measure!(measure_name)
|
|
54
|
+
sort = sort!(sort)
|
|
49
55
|
relation = filter(on || model.all, where)
|
|
50
56
|
return measure.apply(relation) if by.nil?
|
|
51
57
|
|
|
@@ -57,7 +63,7 @@ module Janela
|
|
|
57
63
|
bucketed(measure, dimension, relation, granularity).transform_keys { |bucket| dimension.label(bucket, granularity) }
|
|
58
64
|
else
|
|
59
65
|
relation = within_keys(relation, dimension, keys) if keys
|
|
60
|
-
grouped = relation.group(dimension.attribute).order(Arel.sql(
|
|
66
|
+
grouped = relation.group(dimension.attribute).order(Arel.sql(ordering(measure, dimension, sort)))
|
|
61
67
|
grouped = grouped.limit(limit ? limit!(limit) : MAXIMUM)
|
|
62
68
|
measure.apply(grouped).transform_keys { |value| value.nil? ? Dimension::NONE : value }
|
|
63
69
|
end
|
|
@@ -124,6 +130,15 @@ module Janela
|
|
|
124
130
|
nil # no database to ask yet; a query will raise on its own if it cannot run
|
|
125
131
|
end
|
|
126
132
|
|
|
133
|
+
# nil is the default, so a caller that never heard of a sort is unchanged.
|
|
134
|
+
def sort!(value)
|
|
135
|
+
return "desc" if value.blank?
|
|
136
|
+
|
|
137
|
+
value = value.to_s
|
|
138
|
+
raise BadRequest, "sort must be one of #{SORTS.join(', ')}, got #{value.inspect}" unless SORTS.include?(value)
|
|
139
|
+
value
|
|
140
|
+
end
|
|
141
|
+
|
|
127
142
|
def limit!(value)
|
|
128
143
|
limit = Integer(value, exception: false)
|
|
129
144
|
raise BadRequest, "limit must be a whole number from 1 to #{MAXIMUM}, got #{value.inspect}" unless limit&.between?(1, MAXIMUM)
|
|
@@ -147,6 +162,24 @@ module Janela
|
|
|
147
162
|
end
|
|
148
163
|
|
|
149
164
|
private
|
|
165
|
+
# The label comes second in a measure order so that groups tied on the
|
|
166
|
+
# measure come out the same way every time, and a limit between them
|
|
167
|
+
# always shows the same one. IS NULL puts the (none) group last wherever
|
|
168
|
+
# the database would put it: SQLite sorts NULL first in an ascending
|
|
169
|
+
# order and PostgreSQL last, and a label order would otherwise differ by
|
|
170
|
+
# host (ADR 055). The database sorts the column as its own type, so a
|
|
171
|
+
# number is in numeric order and a date in date order.
|
|
172
|
+
def ordering(measure, dimension, sort)
|
|
173
|
+
label = dimension.quoted_column
|
|
174
|
+
by_label = "#{label} IS NULL, #{label}"
|
|
175
|
+
case sort
|
|
176
|
+
when "asc" then "#{measure.order_by} ASC, #{by_label} ASC"
|
|
177
|
+
when "label" then "#{by_label} ASC"
|
|
178
|
+
when "label_desc" then "#{by_label} DESC"
|
|
179
|
+
else "#{measure.order_by} DESC, #{by_label} ASC"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
150
183
|
def within_keys(relation, dimension, keys)
|
|
151
184
|
labels = keys - [ Dimension::NONE ]
|
|
152
185
|
condition = dimension.attribute.in(labels)
|
data/lib/janela/doctor.rb
CHANGED
|
@@ -148,7 +148,7 @@ module Janela
|
|
|
148
148
|
# the same thing twice.
|
|
149
149
|
ADDED_COLUMNS = {
|
|
150
150
|
"Janela::Frame" => %w[key default_model default_where],
|
|
151
|
-
"Janela::Pane" => %w[kind heading body link partial height prominence companions value_labels],
|
|
151
|
+
"Janela::Pane" => %w[kind heading body link partial height prominence companions value_labels sort],
|
|
152
152
|
"Janela::Snapshot" => %w[owner_type owner_id]
|
|
153
153
|
}.freeze
|
|
154
154
|
|
data/lib/janela/tools.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Janela
|
|
|
17
17
|
|
|
18
18
|
# The attributes of a pane a caller may set: the ones the README names. The
|
|
19
19
|
# frame, the position and the timestamps are not an agent's to write.
|
|
20
|
-
PANE_ATTRIBUTES = %w[kind model measure dimension renderer granularity limit span title height prominence
|
|
20
|
+
PANE_ATTRIBUTES = %w[kind model measure dimension renderer granularity limit sort span title height prominence
|
|
21
21
|
value_labels companions heading body link partial].freeze
|
|
22
22
|
DIRECTIONS = %w[up down].freeze
|
|
23
23
|
READS = %w[describe_vocabulary list_frames get_frame read_pane].freeze
|
|
@@ -64,7 +64,8 @@ module Janela
|
|
|
64
64
|
{ models: Janela.definitions.map { |definition| model_hash(definition) },
|
|
65
65
|
renderers: Janela.renderers.map(&:to_s),
|
|
66
66
|
granularities: Janela.granularities.map(&:to_s),
|
|
67
|
-
limits: Pane::OFFERED_LIMITS
|
|
67
|
+
limits: Pane::OFFERED_LIMITS,
|
|
68
|
+
sorts: Janela.sorts }
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
def list_frames
|
|
@@ -205,6 +206,7 @@ module Janela
|
|
|
205
206
|
renderer: { type: "string", enum: Janela.renderers.map(&:to_s) },
|
|
206
207
|
granularity: { type: "string", enum: Janela.granularities.map(&:to_s) },
|
|
207
208
|
limit: { type: "integer", minimum: Pane::LIMITS.min, maximum: Pane::LIMITS.max },
|
|
209
|
+
sort: { type: "string", enum: Janela.sorts },
|
|
208
210
|
span: { type: "integer", minimum: Pane::SPANS.min, maximum: Pane::SPANS.max },
|
|
209
211
|
title: { type: "string" },
|
|
210
212
|
height: { type: "integer", enum: Pane::HEIGHTS.to_a },
|
data/lib/janela/version.rb
CHANGED
data/lib/janela.rb
CHANGED
|
@@ -170,6 +170,10 @@ module Janela
|
|
|
170
170
|
# The steps a pane's height and prominence take, and how many companion
|
|
171
171
|
# columns a table may carry, for a page that offers them as controls
|
|
172
172
|
# (ADR 027, ADR 047, ADR 050, ADR 051).
|
|
173
|
+
def self.sorts
|
|
174
|
+
Definition::SORTS
|
|
175
|
+
end
|
|
176
|
+
|
|
173
177
|
def self.heights
|
|
174
178
|
Pane::HEIGHTS.to_a
|
|
175
179
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: janela
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jay Killeen
|
|
@@ -147,6 +147,7 @@ files:
|
|
|
147
147
|
- db/migrate/20260930000002_add_prominence_to_janela_panes.rb
|
|
148
148
|
- db/migrate/20260930000003_add_companions_to_janela_panes.rb
|
|
149
149
|
- db/migrate/20261006000001_add_value_labels_to_janela_panes.rb
|
|
150
|
+
- db/migrate/20261013000001_add_sort_to_janela_panes.rb
|
|
150
151
|
- docs/agents.md
|
|
151
152
|
- docs/composing.md
|
|
152
153
|
- docs/decisions/001-built-to-be-forked.md
|
|
@@ -203,6 +204,7 @@ files:
|
|
|
203
204
|
- docs/decisions/052-how-the-project-is-run.md
|
|
204
205
|
- docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md
|
|
205
206
|
- docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md
|
|
207
|
+
- docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md
|
|
206
208
|
- docs/decisions/INDEX.md
|
|
207
209
|
- docs/multi-tenancy.md
|
|
208
210
|
- docs/naming.md
|
|
@@ -229,17 +231,15 @@ metadata:
|
|
|
229
231
|
bug_tracker_uri: https://github.com/retail-tasker/janela/issues
|
|
230
232
|
rubygems_mfa_required: 'true'
|
|
231
233
|
post_install_message: |
|
|
232
|
-
Janela 0.
|
|
234
|
+
Janela 0.15.0: one migration, if you use stored frames.
|
|
233
235
|
|
|
234
|
-
A pane can now
|
|
235
|
-
adds a nullable
|
|
236
|
+
A category pane can now be sorted by its measure either way, or by its
|
|
237
|
+
label (ADR 055), which adds a nullable sort column to janela_panes. Run
|
|
236
238
|
bin/rails janela:install:migrations and then bin/rails db:migrate. If
|
|
237
239
|
you never use stored frames there is nothing to do.
|
|
238
240
|
|
|
239
|
-
Also:
|
|
240
|
-
|
|
241
|
-
pane Janela will not draw is now that pane's own sentence and no
|
|
242
|
-
longer an error for the whole host page.
|
|
241
|
+
Also: groups that tie on the measure now come out in label order, so
|
|
242
|
+
a pane with a limit shows the same rows every time.
|
|
243
243
|
|
|
244
244
|
Steps: UPGRADING.md in this gem, or
|
|
245
245
|
https://github.com/retail-tasker/janela/blob/main/UPGRADING.md
|