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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 507d6baa4984a11605225ee2f97d8795f606e1d1b4739bb7d63567702d6a76c5
4
- data.tar.gz: b12856e553a97feed8c12c56f78774addd360a603b8fc56cb81b01ee6167fe34
3
+ metadata.gz: c7fbc539879d1f11c15536c9305299ab9a754add8e26bb443cb582ea236823be
4
+ data.tar.gz: 150f1292e5c2789cb378e6438c91fd94b766e10088f4daa4aea445ba63341a1b
5
5
  SHA512:
6
- metadata.gz: 65c9dd8f4ea0efc927fe648e52bd519ef7485d9cfda48bc200f4371cc33d8c844d5a496b9de9af7396b256306b5549614fe3235a1742774c118fc26b3b3e6eac
7
- data.tar.gz: 70cac5044f03d17ebf4bd56f5407005815c5945f0979b869d93116d26b4c97d636dd53b18f973d1917efe54ce040d66014bd6e717d9135d66caea1d0a094389c
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
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2026 Jay Killeen
3
+ Copyright (c) 2026 Retail Tasker
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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.3"
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[status_eq]=paid` renders filtered before any JavaScript runs. A pane ignores filters on its own dimension, so clicking a value re-scopes the rest of the dashboard rather than collapsing the pane you clicked. Time panes re-scope with the others but are not click sources yet; drill-down is the next decision. The selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read. A pane with no matching rows renders a `.janela-empty` paragraph. A group whose dimension is null is labelled `(none)` and filters with Ransack's null predicate rather than an empty string. Only models that declare a `janela` block can be requested over HTTP.
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 main_app.new_session_path unless user_signed_in?
356
+ redirect_to new_session_path unless user_signed_in?
318
357
  end
319
358
  end
320
359
  ```
321
360
 
322
- Two details matter. It is *prepended* so it runs before any filter on your `ApplicationController` that assumes a signed-in user (tenant lookups, audit logging). It redirects through `main_app` because Janela is an isolated engine, so a bare `new_session_path` inside it resolves against Janela's own routes and fails.
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.3.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering, bar and line charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames and the engine's own pages for reading and editing them 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).
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.3.0 # or bump your version range
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: String, formatted: Array }
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: (_event, elements) => {
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
- if (key) this.dispatch("toggle", { detail: { key, value } })
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 that bar is.
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
- !selected || label === selected ? "rgba(54, 162, 235, 0.9)" : "rgba(54, 162, 235, 0.25)"
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 filter toggle.
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
- if (filters[key] === String(value)) {
17
- delete filters[key]
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
- filters[key] = String(value)
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 connects, handing back the value it
30
- // just read from the attribute the server rendered, so nothing has changed
31
- // yet. A frame rendered from rows is already showing the right numbers
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
- filtersValueChanged(filters, previous) {
35
- if (previous === undefined || JSON.stringify(filters) === JSON.stringify(previous)) return
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 = new URL(pane.dataset.janelaSrc, window.location.origin)
39
- this.writeFilters(url)
40
- if (pane.src !== url.href) pane.src = url.href
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
- // Sorted so the browser serialises filters the same way the server does and
59
- // an unchanged src is never reloaded.
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
- url.searchParams.set(`q[${key}]`, this.filtersValue[key])
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
  }