janela 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +1 -1
- data/README.md +47 -6
- data/UPGRADING.md +37 -1
- data/app/assets/javascripts/janela/chart_controller.js +12 -6
- data/app/assets/javascripts/janela/frame_controller.js +140 -17
- data/app/assets/javascripts/janela/vitral_controller.js +263 -0
- data/app/assets/stylesheets/janela.css +1 -0
- data/app/assets/stylesheets/vitral.css +343 -0
- data/app/controllers/janela/application_controller.rb +7 -0
- data/app/helpers/janela/frames_helper.rb +2 -1
- data/app/models/janela/query.rb +18 -5
- data/app/views/janela/frames/_frame.html.erb +2 -1
- data/app/views/janela/queries/_query.html.erb +2 -2
- data/app/views/layouts/janela/application.html.erb +10 -1
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +1 -0
- data/docs/decisions/022-host-route-helpers-work-inside-the-engine.md +104 -0
- data/docs/decisions/023-vitral-is-a-theme-not-the-stylesheet.md +86 -0
- data/docs/decisions/024-selecting-more-than-one-value.md +127 -0
- data/docs/decisions/INDEX.md +4 -1
- data/docs/naming.md +172 -0
- data/lib/janela/engine.rb +10 -1
- data/lib/janela/host_routes.rb +31 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +7 -0
- metadata +13 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c7fbc539879d1f11c15536c9305299ab9a754add8e26bb443cb582ea236823be
|
|
4
|
+
data.tar.gz: 150f1292e5c2789cb378e6438c91fd94b766e10088f4daa4aea445ba63341a1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6d3e5390adf6ac5882d621abc014986f0da17cadad2bca3ba3404d9d2f0d4c744130d7163be7271ddfc70c1404250d1a9a3aa09f274d88a44be57c93c1ac8d4
|
|
7
|
+
data.tar.gz: 3d7d77e7022225e40c4c13f150818cfd646b0383400bbc59590014caa877b61894f6a1d0b4f056b4d438a1738b6c1489ead2d4ca60bb58caa9fa7554a15b8637
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ 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.4.0] - 2026-09-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `docs/naming.md`, "Naming Things Is Hard": why Janela's words are uncommon but conceivable, the anatomy of the window, which words stay ordinary and why, how a host puts its own words in front of its users, and a checklist for naming anything new. Ships in the gem.
|
|
13
|
+
- The vitral lattice leans toward the pointer with its outer edge pinned, drifts at two depths as the page scrolls, and reads its lead colour from `--vitral-lattice-ink`, so the live and static lattices cannot disagree.
|
|
14
|
+
- More than one value can be selected in a dimension. Ctrl or Cmd click adds a value and takes it out again while the rest stay; a plain click still selects one and clears the dimension when it was the only one. A chart highlights every selected bar and answers the same modifier. Escape clears the frame's filters. All of it works from the keyboard, because a value is already a real button and a browser puts the same modifier on the click it makes from Enter (ADR 024, #35, #36).
|
|
15
|
+
- `vitral.css`, an optional theme that makes a dashboard a stained glass window: each pane holds one of five colours, dark leading runs between them and the light comes from behind. Separate from `janela.css` on purpose, which stays structure while this is taste (ADR 023). Link it, put `class="vitral"` on the element that carries the light, and use `vitral-pane`, `vitral-panes` and `vitral-button` on your own markup to match. Retheme it from the `--vitral-*` custom properties rather than by forking it.
|
|
16
|
+
- `Janela.theme`, naming the stylesheet Janela's own pages load on top of `janela.css`. Unset by default, so nothing changes for a host that has its own look.
|
|
17
|
+
- `janela/vitral_controller`, optional and separate again: it draws the leadlight live and every node leans toward the pointer. Reduced motion, reduced transparency and increased contrast each get a still, solid window instead.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- The copyright holder is Retail Tasker. The licence is still MIT, and earlier releases keep the notice they shipped with.
|
|
22
|
+
- A click writes Ransack's `_in` rather than `_eq`, one value or five, so there is one shape in the controller, the view and a stored snapshot. A link already shared with `_eq` keeps working and still reads as selected. `Query#selected_value` is now `selected_values` and returns an array, which matters only to a host that overrode a pane view (ADR 024).
|
|
23
|
+
- The null group is exclusive within its dimension. Ransack ands its conditions, so `(none)` together with a value asks for rows that are both null and not, and returns nothing at all. Selecting either now clears the other rather than rendering an empty dashboard that looks like a bug.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- A pane could show numbers for filters nobody had asked for. A pane's first load is lazy, so a request that began before a click could land after it, and Turbo renders whatever arrives and leaves the URL it fetched on the frame. Janela now keeps its own record of what it asked each pane for, and cancels any request for anything else before it can land, rather than trying to correct a wrong render afterwards. A page opened from a filtered link also no longer reloads every pane the moment it connects, which was both wasted work and the most common source of the stale request (#33).
|
|
28
|
+
- Janela's own pages were a dead end: nothing on them linked back to the application they belong to. They now carry one link to the host's root, when the host has one, labelled from i18n like every other word the engine renders. This was not possible before host route helpers resolved inside the engine (ADR 011, ADR 022).
|
|
29
|
+
- A host's own route helpers work inside Janela's controllers and views. `isolate_namespace` pointed every helper at the engine's routes, so host code that runs there and generates a URL raised: an authentication concern redirecting to `new_session_path`, a `rescue_from`, an `after_action`. An unauthenticated visitor got a 500 instead of a sign-in page. Janela now forwards exactly the helpers the engine does not define itself, so nothing of Janela's can be shadowed by a host route of the same name, and `main_app.` still says either unambiguously. Polymorphic `url_for(@record)` has no name to forward and still needs the prefix (ADR 022, #23).
|
|
30
|
+
- The README explained this as something to do "if your app authenticates per controller", which was wrong about the cause. The trigger is generating a URL inside the engine, whenever authentication runs.
|
|
31
|
+
|
|
8
32
|
## [0.3.0] - 2026-09-16
|
|
9
33
|
|
|
10
34
|
### Added
|
|
@@ -98,6 +122,7 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
98
122
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
99
123
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
100
124
|
|
|
125
|
+
[0.4.0]: https://github.com/retail-tasker/janela/releases/tag/v0.4.0
|
|
101
126
|
[0.3.0]: https://github.com/retail-tasker/janela/releases/tag/v0.3.0
|
|
102
127
|
[0.2.1]: https://github.com/retail-tasker/janela/releases/tag/v0.2.1
|
|
103
128
|
[0.2.0]: https://github.com/retail-tasker/janela/releases/tag/v0.2.0
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
PowerBI-style dashboards and cross-filtering slicers, native to Rails and ActiveRecord. Define a dashboard on your models and associations, get a live, sliceable view for internal use, or publish the same definition as a locked, static view for an external audience.
|
|
4
4
|
|
|
5
|
+
**[See it running](https://demo.janela.winontheshelf.com)**, with a year of demo orders. Click any value and the rest of the dashboard re-scopes. The demo is this repository's own test fixture with seed data, so what you click is what ships.
|
|
6
|
+
|
|
5
7
|
## First principle
|
|
6
8
|
|
|
7
9
|
Janela is a PowerBI-style library built on Ruby and Stimulus, meant to drop onto any Ruby on Rails application. No JS framework, no build step of its own, no separate frontend app. Just a gem you add to an existing Rails app's Gemfile and two Stimulus controllers that ship with it.
|
|
@@ -25,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It has t
|
|
|
25
27
|
|
|
26
28
|
```ruby
|
|
27
29
|
# Gemfile
|
|
28
|
-
gem "janela", "~> 0.
|
|
30
|
+
gem "janela", "~> 0.4"
|
|
29
31
|
```
|
|
30
32
|
|
|
31
33
|
```ruby
|
|
@@ -69,6 +71,41 @@ Include the stylesheet in whichever layout renders dashboards. It is small, it i
|
|
|
69
71
|
<%= stylesheet_link_tag "janela" %>
|
|
70
72
|
```
|
|
71
73
|
|
|
74
|
+
### Vitral, the optional theme
|
|
75
|
+
|
|
76
|
+
A *vitral* is a stained glass window, which is what it makes of a dashboard: each pane holds its own colour, dark leading runs between them, and the light comes from behind. It is a second stylesheet, not a replacement, and it is entirely optional. `janela.css` is structure and `vitral.css` is taste, because taste is the first thing you will want to change (ADR 023):
|
|
77
|
+
|
|
78
|
+
```erb
|
|
79
|
+
<%= stylesheet_link_tag "janela" %>
|
|
80
|
+
<%= stylesheet_link_tag "vitral" %>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```erb
|
|
84
|
+
<body class="vitral">
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The class is what carries the light, so nothing is repainted until you ask. Three public classes let your own page join in: `vitral-pane` puts a sheet of the same glass on any element, `vitral-panes` on a container cycles its children through the five colours, and `vitral-button` is a control made of it. Everything else is a custom property, so `--vitral-came`, `--vitral-glass` and the five `--vitral-pane-*` hues retheme the lot from your own stylesheet without touching the gem's.
|
|
88
|
+
|
|
89
|
+
For Janela's own pages, name the theme once and the engine's layout wears it:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
# config/initializers/janela.rb
|
|
93
|
+
Janela.theme = "vitral"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The leadlight can also answer the pointer, with every node leaning toward the cursor. That part is a Stimulus controller and therefore optional twice over, since Janela's own pages load no JavaScript at all (ADR 011):
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import VitralController from "@retail-tasker/janela/vitral_controller" // or "janela/vitral_controller" on importmap
|
|
100
|
+
application.register("vitral", VitralController)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```erb
|
|
104
|
+
<body class="vitral" data-controller="vitral">
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Anyone who has asked for reduced motion, reduced transparency or more contrast gets a still, solid window instead. A browser with no `backdrop-filter` gets plain panels.
|
|
108
|
+
|
|
72
109
|
Requires Rails 8.0+ and Ruby 3.3+. If your app is on Rails 8.1.x with the `json` gem at 3.x, encrypted cookie reads raise inside ActiveSupport and every Turbo Frame request will 500 in the browser; pin `gem "json", "< 3"` until Rails ships the fix.
|
|
73
110
|
|
|
74
111
|
## Usage
|
|
@@ -237,7 +274,9 @@ A host with no tenancy defines nothing, gets a nil owner, and is correct: nothin
|
|
|
237
274
|
|
|
238
275
|
### Filters and clicks
|
|
239
276
|
|
|
240
|
-
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[
|
|
277
|
+
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.
|
|
278
|
+
|
|
279
|
+
**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.
|
|
241
280
|
|
|
242
281
|
### Pane URLs
|
|
243
282
|
|
|
@@ -314,12 +353,14 @@ Janela's controllers inherit from your `ApplicationController`, so they are exac
|
|
|
314
353
|
# config/initializers/janela.rb
|
|
315
354
|
Rails.application.config.to_prepare do
|
|
316
355
|
Janela::ApplicationController.prepend_before_action do
|
|
317
|
-
redirect_to
|
|
356
|
+
redirect_to new_session_path unless user_signed_in?
|
|
318
357
|
end
|
|
319
358
|
end
|
|
320
359
|
```
|
|
321
360
|
|
|
322
|
-
|
|
361
|
+
It is *prepended* so it runs before any filter on your `ApplicationController` that assumes a signed-in user (tenant lookups, audit logging).
|
|
362
|
+
|
|
363
|
+
Your own route helpers work in there. Janela is an isolated engine, so a bare `new_session_path` would normally resolve against Janela's routes and raise, and this bites any host code that generates a URL while inside the engine: an authentication concern, a `rescue_from` that redirects, an `after_action`. Janela forwards the route helpers it does not define itself to your application, so they behave as they do everywhere else (ADR 022). Two things to know. A name Janela also uses means Janela's in here, and `main_app.frames_path` says yours. And `url_for(@record)` resolves polymorphically with no name to forward, so that one still needs `main_app.`.
|
|
323
364
|
|
|
324
365
|
Scoping is automatic when you use Pundit: `Janela::ApplicationController` calls `policy_scope(model)` if your `ApplicationController` defines it, and falls back to `model.all` otherwise. Every model you put on a dashboard needs a policy with a `Scope`, and so do `Janela::Frame` and `Janela::Snapshot`: frames, pane rows and stored panes are all read through the scope, never around it. `test/dummy/app/controllers/application_controller.rb` is the smallest honest example of the wiring.
|
|
325
366
|
|
|
@@ -335,7 +376,7 @@ work at all, which is enough to navigate on the day you install it:
|
|
|
335
376
|
/insights/3 one frame
|
|
336
377
|
```
|
|
337
378
|
|
|
338
|
-
Both go through your `policy_scope`, so a frame another tenant owns is a 404.
|
|
379
|
+
Both go through your `policy_scope`, so a frame another tenant owns is a 404. Each page carries a link back to your application's root, so they are not a dead end; rename it in your own locale file under `janela.actions.home`, or override the engine's layout if you want your whole navigation there.
|
|
339
380
|
|
|
340
381
|
These pages load Janela's own stylesheet and nothing of yours, because the gem
|
|
341
382
|
cannot know your asset names or bundler. Two consequences worth knowing. They
|
|
@@ -394,7 +435,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
394
435
|
|
|
395
436
|
## Status
|
|
396
437
|
|
|
397
|
-
**v0.
|
|
438
|
+
**v0.4.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar and line charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames, 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. Not yet built: a visual editor, drill-down on time panes, other chart types. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
|
|
398
439
|
|
|
399
440
|
## Development
|
|
400
441
|
|
data/UPGRADING.md
CHANGED
|
@@ -12,6 +12,42 @@ bin/rails janela:doctor
|
|
|
12
12
|
|
|
13
13
|
It reads your application and lists what still needs changing.
|
|
14
14
|
|
|
15
|
+
## 0.3.0 to 0.4.0
|
|
16
|
+
|
|
17
|
+
Selecting more than one value in a dimension (ADR 024). Most hosts do
|
|
18
|
+
nothing: the gesture, the predicate and the keys all arrive on their
|
|
19
|
+
own. Two things need you only if you have reached into Janela's own
|
|
20
|
+
markup.
|
|
21
|
+
|
|
22
|
+
**1. `selected_value` is now `selected_values`.**
|
|
23
|
+
|
|
24
|
+
Only if you overrode a pane view. It returns an array, because a
|
|
25
|
+
dimension can now hold more than one value, and the null group is in
|
|
26
|
+
it like any other label:
|
|
27
|
+
|
|
28
|
+
```erb
|
|
29
|
+
- <% if label.to_s == query.selected_value.to_s %>
|
|
30
|
+
+ <% if query.selected?(label) %>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**2. A click now writes `_in` rather than `_eq`.**
|
|
34
|
+
|
|
35
|
+
A shared link is `?q[status_in][]=paid` instead of `?q[status_eq]=paid`.
|
|
36
|
+
Links you sent before keep working, because Ransack reads both and
|
|
37
|
+
Janela still marks an `_eq` value as selected. You only need to act if
|
|
38
|
+
something of yours parses Janela's URLs or asserts on them, such as a
|
|
39
|
+
test:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
- assert_includes page.current_url, "q[status_eq]=paid"
|
|
43
|
+
+ assert_includes page.current_url, "q[status_in][]=paid"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Nothing else changed.** Ctrl or Cmd click adds a value to a
|
|
47
|
+
selection, Escape clears the frame's filters, and both work from the
|
|
48
|
+
keyboard because a value is a real button and Enter carries the same
|
|
49
|
+
modifier. A chart highlights every selected bar.
|
|
50
|
+
|
|
15
51
|
## 0.2.1 to 0.3.0
|
|
16
52
|
|
|
17
53
|
Two things arrive together. Janela's own words settle on frames and
|
|
@@ -50,7 +86,7 @@ On importmap the path is `janela/frame_controller` rather than
|
|
|
50
86
|
path resolves:
|
|
51
87
|
|
|
52
88
|
```bash
|
|
53
|
-
yarn add github:retail-tasker/janela#v0.
|
|
89
|
+
yarn add github:retail-tasker/janela#v0.4.0 # or bump your version range
|
|
54
90
|
```
|
|
55
91
|
|
|
56
92
|
**3. Rename the helper that wraps your panes.**
|
|
@@ -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:
|
|
12
|
+
selected: Array, formatted: Array }
|
|
13
13
|
|
|
14
14
|
connect() {
|
|
15
15
|
this.chart = new Chart(this.element, {
|
|
@@ -33,21 +33,27 @@ export default class extends Controller {
|
|
|
33
33
|
// and disagreeing with the cell beside it.
|
|
34
34
|
tooltip: { callbacks: { label: (item) => this.formattedValue[item.dataIndex] ?? item.formattedValue } }
|
|
35
35
|
},
|
|
36
|
-
onClick: (
|
|
36
|
+
onClick: (event, elements) => {
|
|
37
37
|
if (elements.length === 0) return
|
|
38
38
|
const label = this.labelsValue[elements[0].index]
|
|
39
39
|
const [key, value] = this.filtersValue[String(label)] || []
|
|
40
|
-
|
|
40
|
+
// A custom event carries no modifier flags of its own, so the
|
|
41
|
+
// gesture is read here and passed on (ADR 024).
|
|
42
|
+
const additive = event.native?.ctrlKey === true || event.native?.metaKey === true
|
|
43
|
+
if (key) this.dispatch("toggle", { detail: { key, value, additive } })
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
})
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
// With nothing selected every bar is solid; with a selection only
|
|
49
|
+
// With nothing selected every bar is solid; with a selection only the
|
|
50
|
+
// selected ones are, and there can be more than one of them.
|
|
47
51
|
colours() {
|
|
48
|
-
const selected = this.selectedValue
|
|
52
|
+
const selected = this.selectedValue.map(String)
|
|
49
53
|
return this.labelsValue.map((label) =>
|
|
50
|
-
|
|
54
|
+
selected.length === 0 || selected.includes(String(label))
|
|
55
|
+
? "rgba(54, 162, 235, 0.9)"
|
|
56
|
+
: "rgba(54, 162, 235, 0.25)"
|
|
51
57
|
)
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -7,37 +7,149 @@ export default class extends Controller {
|
|
|
7
7
|
static targets = ["pane"]
|
|
8
8
|
static values = { filters: Object }
|
|
9
9
|
|
|
10
|
+
initialize() {
|
|
11
|
+
this.supersede = this.supersede.bind(this)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A pane is a turbo frame that outlives its own contents, so the listener is
|
|
15
|
+
// attached once per frame rather than once per render.
|
|
16
|
+
paneTargetConnected(pane) {
|
|
17
|
+
pane.addEventListener("turbo:before-fetch-request", this.supersede)
|
|
18
|
+
// What the server already rendered this pane for is what Janela has asked
|
|
19
|
+
// for, so a later change is measured against it rather than against
|
|
20
|
+
// nothing.
|
|
21
|
+
pane.dataset.janelaAsked ||= this.urlFor(pane).href
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
paneTargetDisconnected(pane) {
|
|
25
|
+
pane.removeEventListener("turbo:before-fetch-request", this.supersede)
|
|
26
|
+
pane.janelaRequest?.abort()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The request for what Janela last asked for wins, and any other is
|
|
30
|
+
// cancelled before it can land. A pane's load can be in flight when a click
|
|
31
|
+
// asks it for something else, and Turbo renders whatever arrives, so an
|
|
32
|
+
// older answer could overwrite a newer one and leave the pane showing
|
|
33
|
+
// numbers for filters nobody has any more. Neither arrival order nor start
|
|
34
|
+
// order settles it: a lazy load can begin after a click and still be for the
|
|
35
|
+
// old address. What was asked for is the only honest rule (#33).
|
|
36
|
+
supersede(event) {
|
|
37
|
+
const pane = event.currentTarget
|
|
38
|
+
const options = event.detail?.fetchOptions
|
|
39
|
+
const url = event.detail?.url
|
|
40
|
+
if (!options || !url) return
|
|
41
|
+
|
|
42
|
+
const request = new AbortController()
|
|
43
|
+
options.signal?.addEventListener("abort", () => request.abort(), { once: true })
|
|
44
|
+
options.signal = request.signal
|
|
45
|
+
|
|
46
|
+
const asked = pane.dataset.janelaAsked
|
|
47
|
+
if (asked && new URL(url, window.location.origin).href !== asked) {
|
|
48
|
+
// Stale before it started. Cancel it, and make sure the pane still ends
|
|
49
|
+
// up fetching what was asked for, since this may have been its only load.
|
|
50
|
+
request.abort()
|
|
51
|
+
// Bounded, so an address spelled differently from the one Janela built
|
|
52
|
+
// cannot set a pane correcting itself forever.
|
|
53
|
+
const corrections = pane.janelaCorrectedFor === asked ? (pane.janelaCorrections || 0) : 0
|
|
54
|
+
if ((!pane.janelaRequest || pane.janelaRequestFor !== asked) && corrections < 2) {
|
|
55
|
+
pane.janelaCorrectedFor = asked
|
|
56
|
+
pane.janelaCorrections = corrections + 1
|
|
57
|
+
queueMicrotask(() => { pane.src === asked ? pane.reload() : (pane.src = asked) })
|
|
58
|
+
}
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pane.janelaRequest?.abort()
|
|
63
|
+
pane.janelaRequest = request
|
|
64
|
+
pane.janelaRequestFor = asked
|
|
65
|
+
request.signal.addEventListener("abort", () => {
|
|
66
|
+
if (pane.janelaRequest === request) pane.janelaRequest = null
|
|
67
|
+
}, { once: true })
|
|
68
|
+
}
|
|
69
|
+
|
|
10
70
|
// Table buttons send key/value as Stimulus action params; charts dispatch a
|
|
11
|
-
// custom event carrying them in detail. Either way it is one
|
|
71
|
+
// custom event carrying them in detail. Either way it is one value being
|
|
72
|
+
// added to or taken out of a selection.
|
|
73
|
+
//
|
|
74
|
+
// A plain click selects that value alone, or clears the dimension if it was
|
|
75
|
+
// the only one selected. Ctrl or Cmd adds and removes while the rest stay,
|
|
76
|
+
// which is how every list in every operating system already behaves. The
|
|
77
|
+
// same event carries the same flags when Enter is pressed on a focused
|
|
78
|
+
// value, so the keyboard needs nothing of its own (ADR 024).
|
|
12
79
|
toggle(event) {
|
|
13
80
|
const { key, value } = { ...event.detail, ...event.params }
|
|
81
|
+
const additive = event.ctrlKey || event.metaKey || event.detail?.additive === true
|
|
14
82
|
const filters = { ...this.filtersValue }
|
|
83
|
+
const selected = this.valuesFor(filters, key).includes(String(value))
|
|
15
84
|
|
|
16
|
-
|
|
17
|
-
|
|
85
|
+
// The null group asks for rows that have nothing there, so it cannot be
|
|
86
|
+
// combined with a value: Ransack ands its conditions, and the pair matches
|
|
87
|
+
// no row at all. It is exclusive within its dimension instead.
|
|
88
|
+
this.clearDimension(filters, key)
|
|
89
|
+
|
|
90
|
+
if (key.endsWith("_null")) {
|
|
91
|
+
if (!selected) filters[key] = "1"
|
|
18
92
|
} else {
|
|
19
|
-
|
|
93
|
+
let values = additive ? this.valuesFor(this.filtersValue, key) : []
|
|
94
|
+
values = selected ? values.filter((each) => each !== String(value)) : [ ...values, String(value) ]
|
|
95
|
+
if (values.length) filters[key] = [ ...new Set(values) ].sort()
|
|
20
96
|
}
|
|
21
97
|
|
|
22
98
|
this.filtersValue = filters
|
|
23
99
|
}
|
|
24
100
|
|
|
25
101
|
clear() {
|
|
26
|
-
this.filtersValue = {}
|
|
102
|
+
if (Object.keys(this.filtersValue).length) this.filtersValue = {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Whatever is selected for one key, as a set of strings. A filter arrives as
|
|
106
|
+
// an array from a click and as a string from a hand written _eq link.
|
|
107
|
+
valuesFor(filters, key) {
|
|
108
|
+
const held = filters[key]
|
|
109
|
+
if (held === undefined) return []
|
|
110
|
+
|
|
111
|
+
return (Array.isArray(held) ? held : [ held ]).map(String)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Every filter Janela itself writes for the same dimension: the values, the
|
|
115
|
+
// null group, and an _eq that a shared link may still carry. A host's own
|
|
116
|
+
// q[...] filters use other predicates and are left alone (ADR 008).
|
|
117
|
+
clearDimension(filters, key) {
|
|
118
|
+
const base = key.replace(/_(in|null|eq)$/, "")
|
|
119
|
+
for (const suffix of [ "in", "null", "eq" ]) delete filters[`${base}_${suffix}`]
|
|
27
120
|
}
|
|
28
121
|
|
|
29
|
-
// Stimulus calls this as the controller
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// inline, and a src assigned here would make Turbo fetch every pane and
|
|
122
|
+
// Stimulus calls this as the controller starts, with the filters the server
|
|
123
|
+
// rendered, so nothing has changed yet. A frame is already showing the right
|
|
124
|
+
// numbers, and a src assigned here would make Turbo fetch every pane and
|
|
33
125
|
// throw that first render away (ADR 014).
|
|
34
|
-
|
|
35
|
-
|
|
126
|
+
//
|
|
127
|
+
// Janela keeps its own record of what it last applied rather than trusting
|
|
128
|
+
// the previous value it is handed. On a page opened from a filtered link,
|
|
129
|
+
// Stimulus passes the default empty object as the previous value, not
|
|
130
|
+
// nothing, so a check on that alone reloaded every pane on connect, and the
|
|
131
|
+
// redundant load could land after a click and undo it (#33).
|
|
132
|
+
filtersValueChanged(filters) {
|
|
133
|
+
const applied = JSON.stringify(filters)
|
|
134
|
+
if (this.applied === undefined || this.applied === applied) {
|
|
135
|
+
this.applied = applied
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
this.applied = applied
|
|
36
139
|
|
|
37
140
|
this.paneTargets.forEach((pane) => {
|
|
38
|
-
const url =
|
|
39
|
-
|
|
40
|
-
|
|
141
|
+
const url = this.urlFor(pane)
|
|
142
|
+
|
|
143
|
+
// Janela's own record of what it last asked this pane for, rather than
|
|
144
|
+
// the pane's src. A lazy load that began before a click lands after it,
|
|
145
|
+
// and Turbo then puts the URL it fetched back on the frame while leaving
|
|
146
|
+
// the newer content in place, so src stops describing what is on screen.
|
|
147
|
+
// Reading it meant the next change that happened to match was skipped
|
|
148
|
+
// and the pane kept numbers nobody had asked for (#33).
|
|
149
|
+
if (pane.dataset.janelaAsked === url.href) return
|
|
150
|
+
|
|
151
|
+
pane.dataset.janelaAsked = url.href
|
|
152
|
+
pane.src = url.href
|
|
41
153
|
})
|
|
42
154
|
|
|
43
155
|
this.syncPageUrl()
|
|
@@ -55,11 +167,22 @@ export default class extends Controller {
|
|
|
55
167
|
if (url.href !== window.location.href) history.replaceState(history.state, "", url)
|
|
56
168
|
}
|
|
57
169
|
|
|
58
|
-
|
|
59
|
-
|
|
170
|
+
urlFor(pane) {
|
|
171
|
+
const url = new URL(pane.dataset.janelaSrc, window.location.origin)
|
|
172
|
+
this.writeFilters(url)
|
|
173
|
+
return url
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Sorted, keys and values both, so the browser serialises a selection the
|
|
177
|
+
// same way every time and an unchanged src is never reloaded.
|
|
60
178
|
writeFilters(url) {
|
|
61
179
|
for (const key of Object.keys(this.filtersValue).sort()) {
|
|
62
|
-
|
|
180
|
+
const held = this.filtersValue[key]
|
|
181
|
+
if (Array.isArray(held)) {
|
|
182
|
+
for (const value of [ ...held ].sort()) url.searchParams.append(`q[${key}][]`, value)
|
|
183
|
+
} else {
|
|
184
|
+
url.searchParams.set(`q[${key}]`, held)
|
|
185
|
+
}
|
|
63
186
|
}
|
|
64
187
|
}
|
|
65
188
|
}
|