janela 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +36 -5
  4. data/UPGRADING.md +111 -0
  5. data/app/assets/javascripts/janela/frame_controller.js +15 -0
  6. data/app/assets/stylesheets/janela.css +29 -0
  7. data/app/controllers/janela/application_controller.rb +7 -0
  8. data/app/controllers/janela/panes_controller.rb +22 -7
  9. data/app/controllers/janela/queries_controller.rb +2 -1
  10. data/app/helpers/janela/frames_helper.rb +26 -7
  11. data/app/models/janela/frame.rb +20 -0
  12. data/app/models/janela/pane.rb +67 -4
  13. data/app/models/janela/query.rb +7 -2
  14. data/app/views/janela/frames/_content.html.erb +26 -0
  15. data/app/views/janela/frames/_frame.html.erb +9 -1
  16. data/app/views/janela/frames/_pane.html.erb +2 -2
  17. data/app/views/janela/panes/_content_form.html.erb +33 -0
  18. data/app/views/janela/panes/_row.html.erb +1 -1
  19. data/app/views/janela/panes/edit.html.erb +5 -1
  20. data/app/views/janela/panes/new.html.erb +6 -1
  21. data/app/views/janela/queries/_query.html.erb +15 -11
  22. data/config/locales/en.yml +5 -0
  23. data/db/migrate/20260924000001_add_content_to_janela_panes.rb +16 -0
  24. data/db/migrate/20260924000002_add_key_to_janela_frames.rb +10 -0
  25. data/docs/composing.md +269 -0
  26. data/docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md +1 -0
  27. data/docs/decisions/035-a-check-does-what-janela-does-or-says-what-it-saw.md +262 -0
  28. data/docs/decisions/036-janela-publishes-what-a-theme-may-target.md +171 -0
  29. data/docs/decisions/037-what-1-0-means.md +172 -0
  30. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +194 -0
  31. data/docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md +168 -0
  32. data/docs/decisions/040-a-host-can-fix-a-frames-filter.md +145 -0
  33. data/docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md +112 -0
  34. data/docs/decisions/042-a-charts-title-is-a-figcaption.md +174 -0
  35. data/docs/decisions/INDEX.md +26 -15
  36. data/docs/multi-tenancy.md +22 -0
  37. data/docs/naming.md +7 -0
  38. data/docs/roadmap.md +218 -0
  39. data/docs/theming.md +179 -0
  40. data/lib/janela/definition.rb +16 -1
  41. data/lib/janela/doctor.rb +121 -32
  42. data/lib/janela/model.rb +6 -1
  43. data/lib/janela/version.rb +1 -1
  44. data/lib/janela.rb +47 -3
  45. metadata +30 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91a8da061f836f0c93ed3c6005f704fc8c5830ea2fb8e73f2f77c96f2107c467
4
- data.tar.gz: ace46d3c7c08ea5423de350f14dcd17e6a303528f62f2a3c60b66b9bf2cc88bf
3
+ metadata.gz: e1dbbea1c971d06f91daeb2e1bbd3ecc0a9d056363efd0e61664bf9cac51da9b
4
+ data.tar.gz: 50e76f042113f5e5e071f20d242c62176672aba367ab676448f6e722f1df9f2d
5
5
  SHA512:
6
- metadata.gz: ce044bb0f401c9541a69952212b22492c94d3d68a7a7ce05af8e568b0a77c16adf41b8153291cbc981c80ad2343a16122989890c00ce6feeb5ee3200605ebc82
7
- data.tar.gz: 1244329924141334f04b3d4f4ac0f15834862d110d0577f5a3739ff2085549a790d76691941df362080a1b470de6927773a3a4cd0cda963f62cb3ca31e0640a4
6
+ metadata.gz: 2a96c9be9933a2c7f4e33109bd5bd63f8fe6a0003047e66a3ab28f306bf33aa5d8be3f8be860ce306d4a994775749dcd410ca64998d8c856916caa048e3ab1c2
7
+ data.tar.gz: b9d548a0042e569e3cd9ceaae714af366b852a1a81ec95966bbd0a1b7b21f1978d352f560192fad01b87acc14d65d0dddb652ec443702eda5ffabeb61507d32f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ 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.9.0] - 2026-09-28
9
+
10
+ ### Added
11
+
12
+ - `Janela::Frame.for(owner, key)`, so your code finds a frame it keeps for one of its pages: `Janela::Frame.for(account, :overview)`, `Janela::Frame.for(queue, :analytics)`. Finding by owner alone worked while an owner had one frame, and a tenant has several, so `find_by(owner: account)` returned the first frame the tenant ever made rather than the one the page was for. A frame now carries a nullable `key`, unique per owner in the database, which you set and nothing else reads: never in a URL or the engine's forms, so an analyst can rename a keyed frame without detaching it. The frame is created on first use, named after its key unless the block you pass says otherwise. Not the slug ADR 013 turned down, and the ADR says why. Needs a migration; see `UPGRADING.md` (ADR 041, #59).
13
+ - **A pane can hold words.** A stored frame rendered its panes and nothing else, and a pane had to name a measure, so the analyst who owns a frame could choose every number on it and label none of them. A pane now has a `kind`: `query`, today's pane and the default, `text`, a heading, a body and a link the analyst writes, and `partial`, a partial you wrote under `app/views/janela_content/` that the analyst places by name and hands the same three strings, together with `pane`, `frame` and `where`, the filter fixed for the render, so a partial showing a figure scopes it from the frame rather than from whichever page it is on (#60). The reader's `q[...]` is not passed, since a content pane is not refreshed on a click. What an analyst writes is escaped, a link must be a path on your own site, and no HTML, Markdown or template is ever stored in a row, so the promise of ADR 012 still holds: an analyst arranges and writes words, and only code writes markup. Rendered as `div.janela-pane.janela-content`, with `janela-content-heading` on a text pane's heading, both added to the theming contract. Needs a migration; see `UPGRADING.md` (ADR 039, #58).
14
+ - `janela_frame` takes `where:`, a filter the host fixes for one render: `janela_frame @frame, where: { queue_id_eq: @queue.id }`, or the same on the block form. It is for one frame shown on every record's page, narrowed to that record. Until now the only way was the page URL's `q[...]`, and that is the reader's: Clear filters, Escape, and a click on the same dimension all removed it, and the page then showed every record the reader could see under one record's heading. `where:` travels in each pane's URL as `where[...]`, which nothing the reader does touches, is applied before the reader's own filters so theirs can only narrow inside it, and is bounded exactly as `q[...]` is. A pane repointed with `janela--frame:repoint` keeps it. It is a view filter and not a permission: keep rows out of a reader's reach in `policy_scope` (ADR 040, #57).
15
+
16
+ ### Changed
17
+
18
+ - The roadmap is called **Vista**, and the half of it beyond 1.0 is **Horizonte**. `docs/roadmap.md` keeps its path and its URL, so every link into it still resolves; what changed is the page's title, the demo's navigation, and `docs/naming.md`, which now carries both words in the table with the rest of the window's vocabulary. Janela is a window, so the document saying where the project is going is the view through it, and the far half of that view is the horizon. The table is where a forker looks to see whether a name was reasoned about or reached for, which is the only reason a rename like this is worth writing down. The page also gains a drawing of what it describes: two receding bands under an empty sky, eight marks on the near one for the issues in 1.0 and three on the far one for the work past it, drawn in `currentColor` so it reads in a light or a dark theme without knowing which it is in (ADR 037).
19
+ - **Breaking.** A chart pane's title is a visible `<figcaption>` inside a `<figure>` wrapping the canvas, not only an `aria-label`. A table's caption and a single value's label were both visible text; a chart's was announced to a screen reader and shown to nobody, which #26's own investigation had already found and left standing. `janela-pane` moves from the `<canvas>` to the `<figure>`; `janela-chart` stays on the canvas, so anything that selected it alone is unaffected. The canvas's accessible name is now `aria-labelledby`, pointing at the figcaption, rather than a second copy of the string in `aria-label`. `janela-own-headings` hides the new figcaption the same way it already hides a table's caption and a value's label. `janela-chart-title` joins the theming contract; see `UPGRADING.md` (ADR 042, #61).
20
+
21
+ ## [0.8.0] - 2026-09-23
22
+
23
+ ### Added
24
+
25
+ - `docs/theming.md`, "Theming Janela": the contract a theme may target. Every class the engine renders in your own pages, the three custom properties that carry `janela.css`, what a theme is expected to leave alone, and how to write one of your own. A theme is any stylesheet you name, `Janela.theme = "midnight"` resolving from your own asset paths, and vitral is one of them rather than the one. Ships in the gem; `test/dummy` has a live page at `/vitral` showing all of it against real panes (ADR 036).
26
+ - `janela-own-headings`, a class you put on any ancestor when your own markup already says what a pane is. A table pane's `<caption>` and a single value's label stop being drawn and stay in the accessibility tree. Hidden rather than removed on purpose: a caption is the table's accessible name, so `display: none` lands a screen reader on a grid of numbers with nothing to say what they measure, which is the easy wrong answer this saves you writing. A chart pane needs nothing, since its title was only ever an `aria-label` (ADR 036, #26).
27
+ - `docs/roadmap.md`, "Where Janela Is Going": what 1.0 means, which open issues are in it, and what is deliberately not coming. Three claims that can be checked rather than a feature count, since ADR 001 rules out a 1.0 measured against what a commercial BI tool ships: the public surface stops moving, a pane can be read, and the doctor can be trusted. It ships in the gem and renders on the demo at `/docs/roadmap`, because a reader who has to open an issue tracker to find out where a library is going has been told to do the maintainer's filing. It carries no dates on purpose, so that it can only ever be wrong about substance (ADR 037).
28
+
29
+ ### Changed
30
+
31
+ - **Breaking.** `Janela.parent_controller=` raises when the assignment cannot take effect. The superclass of `Janela::ApplicationController` is resolved once, the first time the class loads, so naming a different controller after that did nothing at all: dashboards kept inheriting whatever was named first, and the host's authentication and `policy_scope` were not the ones it had written. Nothing said so. Set it in `config/initializers/janela.rb`, which runs before anything can load a Janela controller; `config.to_prepare` and `config.after_initialize` are both too late, and the README's own layout recipe is a `to_prepare` block that loads the controller. Naming the controller Janela already inherits is still allowed, since nothing is being asked for (ADR 035, #38).
32
+ - **The class names only Janela's own pages use are no longer public API.** ADR 016 said "the class names are public API", which read literally promised that `janela-card`, `janela-crumb`, `janela-flash`, `janela-button`, `janela-form` and the rest of the engine's own chrome would never be renamed without an upgrade note. That was a promise made to nobody about markup only the engine renders, and it made Janela's own pages harder to change than the library they serve. They are scoped under `janela-page`, which only the engine's layout sets, so they cannot reach your pages, and they may now change in any release. Everything a host's own markup contains, the grid scale and the pane primitives, is the contract and is unchanged: `docs/theming.md` lists it (ADR 036).
33
+
34
+ ### Fixed
35
+
36
+ - Janela believes only the definitions Janela built. `Janela::Model` is extended onto every ActiveRecord model, so `janela` is a question any of them can answer, and an application whose own model defines a class method by that name wins the collision, as Ruby says it should. Janela treated anything truthy that came back as a definition: `rails janela:doctor` raised `NoMethodError: undefined method 'dimensions' for an instance of String`, a named subclass of that model was registered so `/dashboards/<its route key>/...` resolved, and `Janela.definitions` handed out a value that was not a definition to anything iterating it, such as a form offering a choice of model. Nothing about your own method changes; Janela ignores it now instead of choking on it. A *column* named `janela` was never affected either way, because an attribute is an instance method and this is a class method (#54, found while investigating #12).
37
+
38
+ - `rails janela:doctor`'s `through-dimensions-without-an-allowlist` reported once per dimension and once per class inheriting a declaration, where there is only ever one allowlist to write. Two dimensions reading through the same association produced two findings whose suggested lines contradicted each other, `%w[region]` and `%w[name]`, so pasting both kept the second and silently lost the first; an STI family produced a copy of each per subclass, since a subclass shares its parent's associations. It is now one finding per associated class, naming every column that class needs and every dimension that wants one, with a line that allows all of them. A subclass that reflects an association its parent does not still gets a finding of its own, because that is a different fix (#44).
39
+ - `rails janela:doctor` checked whether your controller *defines* `policy_scope` rather than whether calling it works, so a Pundit host with no policy for Janela's own models got a clean report and a `Pundit::NotDefinedError` on every dashboard request. Pundit defines `policy_scope` the moment it is included, whether or not the model has a policy, so for the commonest authorisation library those are different questions. `unscoped-reads` now makes the call Janela makes, against `Janela::Frame` and `Janela::Snapshot`, and reports a raise as an error quoting your own exception, which names the policy to write. ADR 032 called this check "exact: the method is defined or it is not"; ADR 035 supersedes that. Its other claim stands: Pundit raises rather than leaking, so nothing was ever exposed (ADR 035, #51).
40
+ - `frames-nobody-will-own` and `snapshots-nobody-will-see` never fired for any application whose `policy_scope` reaches for the signed in user, which is most of them. Both asked your policy on a controller with no request, where `session`, `params` and `current_user` are unreachable, and a blanket rescue read the resulting exception as "does not filter by owner". The checks now ask the way a request does, so those are empty rather than missing, which is an unauthenticated visitor and the right thing for a check to ask about. Expect findings that were always true and never printed (ADR 035).
41
+ - Every check about your controller read `Janela.parent_controller`, the setting, rather than `Janela::ApplicationController.superclass`, what Janela actually inherits. When a host named a parent controller too late the two differed, and the doctor reported on a class that was not in the chain, including the sentence "Janela's controllers inherit X" about a class it does not (ADR 035, #38).
42
+ - `hardcoded-disallowed-predicates` reported an error saying a file filters a model, having only found the dimension's name followed by a predicate somewhere under `app`, `config` or `lib`. An unrelated model's Ransack call, a comment warning against the predicate, and a key in a locale file all read the same to it, and a host was told an error about code that had nothing to do with Janela. It is now a warning, says the file *mentions* the key, and admits that it cannot tell which model a match belongs to. It also reports once per `janela` declaration rather than once per class inheriting it, so an STI family no longer multiplies the same finding (ADR 021, ADR 035, #51, part of #44).
43
+
8
44
  ## [0.7.0] - 2026-09-22
9
45
 
10
46
  ### Added
@@ -169,6 +205,8 @@ First alpha, installed from GitHub for testing in a single host application.
169
205
  - Only models that declare a `janela` block are addressable over HTTP.
170
206
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
171
207
 
208
+ [0.9.0]: https://github.com/retail-tasker/janela/releases/tag/v0.9.0
209
+ [0.8.0]: https://github.com/retail-tasker/janela/releases/tag/v0.8.0
172
210
  [0.7.0]: https://github.com/retail-tasker/janela/releases/tag/v0.7.0
173
211
  [0.6.0]: https://github.com/retail-tasker/janela/releases/tag/v0.6.0
174
212
  [0.5.0]: https://github.com/retail-tasker/janela/releases/tag/v0.5.0
data/README.md CHANGED
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It has t
27
27
 
28
28
  ```ruby
29
29
  # Gemfile
30
- gem "janela", "~> 0.7"
30
+ gem "janela", "~> 0.9"
31
31
  ```
32
32
 
33
33
  ```ruby
@@ -255,6 +255,37 @@ A frame renders each pane inline on the first response, so the page is a correct
255
255
 
256
256
  A frame may belong to an owner, `belongs_to :owner, polymorphic: true, optional: true`. Janela sets nothing there and reads nothing from it: it exists so a multi tenant host's Pundit `Scope` has a column to filter on. Set it to whatever your tenant is, and leave it null if you have one tenant.
257
257
 
258
+ **A frame your code keeps for one of its pages.** Find it by owner and key, and it is created the first time it is asked for (ADR 041):
259
+
260
+ ```ruby
261
+ @frame = Janela::Frame.for(Current.account, :overview)
262
+ @frame = Janela::Frame.for(queue, :analytics) { |frame| frame.name = "#{queue.name} analytics" }
263
+ ```
264
+
265
+ An owner can have any number of frames, one per key, beside every frame an analyst made from the engine's pages, which have no key. The key is yours: it is never in a URL or a form, so an analyst can rename, regrid and recompose the frame without detaching it from the page that finds it. The block runs only when the frame is created. Deleted from the engine's pages, it comes back empty on the next visit, because the page asks for it. If the owner is one of your records rather than your tenant, your policy scope has to see frames owned by those records; the [multi tenancy guide](docs/multi-tenancy.md) shows how.
266
+
267
+ **Words beside the numbers.** A pane can hold a heading, a paragraph and a link instead of a query, so an analyst can label the frame they own without a deploy (ADR 039):
268
+
269
+ ```ruby
270
+ frame.panes.create!(kind: "text", heading: "Refunds are excluded",
271
+ body: "Figures are in the store's own currency.", link: "/orders", span: 3)
272
+ frame.panes.create!(kind: "partial", partial: "overview_heading", heading: "Project overview", span: 3)
273
+ ```
274
+
275
+ What an analyst writes is escaped, never rendered as markup, and a `link` must be a path on your own site. Anything that needs markup, an icon, an image, a layout, is a partial you write under `app/views/janela_content/`; the analyst places it by name and it receives `heading`, `body` and `link` as locals, and `pane`, `frame` and `where`, the filter you fixed for the render (`{}` when none), so a partial showing a figure can scope it from the frame's owner rather than from the page it is on. The reader's own selection is deliberately not passed: a content pane is not refreshed when they click, so a figure scoped by it would go stale. A partial that computes a figure scopes its own query, through your `policy_scope`, as any of your code does. The directory is the allowlist: a row cannot name any other template. It sits outside `app/views/janela/` on purpose, since a view of yours at an engine's path would replace the engine's own. The engine's pages offer both kinds when adding a pane. A content pane takes no part in cross-filtering and is not in a snapshot, because it has no query.
276
+
277
+ **A frame narrowed to the record whose page it is on.** Pass `where:` and every pane of the frame is filtered by it before anything the reader selects:
278
+
279
+ ```erb
280
+ <%= janela_frame @frame, where: { queue_id_eq: @queue.id } %>
281
+
282
+ <%= janela_frame where: { queue_id_eq: @queue.id } do %>
283
+ <%= janela_pane Ticket, :count, by: :status %>
284
+ <% end %>
285
+ ```
286
+
287
+ It travels in each pane's URL as `where[...]`, apart from the reader's `q[...]`, so Clear filters, Escape and a click on the same dimension cannot take it off, and the reader's selection can only narrow inside it. It is bounded like any filter: declared dimensions only, and the predicates ADR 025 allows. It is a view filter, not a permission: it is visible in the pane URL, and a reader who edits it out sees only what your `policy_scope` already allows them to. Keep a record out of reach in the scope, not here. Putting the same filter in the page URL's `q[...]` instead does not hold, because `q[...]` is the reader's to clear (ADR 040).
288
+
258
289
  ### Janela's own pages
259
290
 
260
291
  The engine serves an index and a page per frame at the mount root, so you can install the gem and navigate the same day:
@@ -428,9 +459,9 @@ It is *prepended* so it runs before any filter on your `ApplicationController` t
428
459
 
429
460
  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.`.
430
461
 
431
- Scoping is automatic when you use Pundit: `Janela::ApplicationController` calls `policy_scope(model)` if your `ApplicationController` defines it, and raises `Janela::Unscoped` if it does not, rather than reading everything on the strength of an omission (ADR 032). An application with nothing to hide answers `def policy_scope(model) = model.all` once and is done; `bin/rails janela:doctor` reports the absence as `unscoped-reads` before a visitor finds it. 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.
462
+ Scoping is automatic when you use Pundit: `Janela::ApplicationController` calls `policy_scope(model)` if your `ApplicationController` defines it, and raises `Janela::Unscoped` if it does not, rather than reading everything on the strength of an omission (ADR 032). An application with nothing to hide answers `def policy_scope(model) = model.all` once and is done; `bin/rails janela:doctor` reports the absence as `unscoped-reads` before a visitor finds it. 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. The doctor checks that by making the call rather than by looking for the method, because Pundit defines `policy_scope` the moment it is included and raises only when the model has no policy, so the two are different questions (ADR 035). `test/dummy/app/controllers/application_controller.rb` is the smallest honest example of the wiring.
432
463
 
433
- **Multi tenancy** has its own guide: [docs/multi-tenancy.md](docs/multi-tenancy.md). It covers what goes through your scope, worked wiring for Pundit, acts_as_tenant and CanCanCan, what owns a frame the analyst creates, and the one rough edge, which is that a snapshot has no owner column yet.
464
+ **Multi tenancy** has its own guide: [docs/multi-tenancy.md](docs/multi-tenancy.md). It covers what goes through your scope, worked wiring for Pundit, acts_as_tenant and CanCanCan, what owns a frame the analyst creates, and what rows a scheduled snapshot freezes.
434
465
 
435
466
  ### The pages Janela serves
436
467
 
@@ -497,13 +528,13 @@ Janela ships the load-bearing core of a BI tool and nothing else. The reasoning
497
528
  - **Querying rides on [Ransack](https://github.com/activerecord-hackery/ransack)'s association-path traversal.** Janela does not invent a query language.
498
529
  - **Cross-filtering is a Stimulus controller plus Turbo Frames.** Click a value in one pane, shared filter state updates, every other frame on the page re-renders.
499
530
  - **Charts are [Chart.js](https://www.chartjs.org)**, driven by one small Stimulus controller from the same values the tables show. Not a charting engine.
500
- - **Publishing creates a Snapshot.** An ActiveJob freezes the result set into a new record; the live dashboard stays editable and the published view is a point-in-time fork, not a toggle on the same record. Not built yet.
531
+ - **Publishing creates a Snapshot.** An ActiveJob freezes the result set into a new record; the live dashboard stays editable and the published view is a point-in-time fork, not a toggle on the same record. The job will not freeze a scope you have not named (ADR 034).
501
532
 
502
533
  Deliberately out of scope: natural-language query, a separate data warehouse, a row-level-security subsystem (use your app's Pundit/CanCanCan), refresh-scheduling UI (schedule the Snapshot job with whatever you already use), embedding SDK, mobile app, print/paginated reports. If you need one of those, the codebase is meant to be small enough to fork and add your own.
503
534
 
504
535
  ## Status
505
536
 
506
- **v0.7.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, STI subclasses, the engine's own pages for reading and editing them and the optional vitral theme work and are covered by unit and real-browser tests. 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).
537
+ **v0.9.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 found by owner and key, panes that hold words or a host partial as well as a query, a host-fixed frame filter no click can remove, STI subclasses, the engine's own pages for reading and editing them and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, drill-down on time panes, other chart types. [Vista](docs/roadmap.md), the roadmap, says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
507
538
 
508
539
  ## Development
509
540
 
data/UPGRADING.md CHANGED
@@ -12,6 +12,117 @@ bin/rails janela:doctor
12
12
 
13
13
  It reads your application and lists what still needs changing.
14
14
 
15
+ ## 0.8.0 to 0.9.0
16
+
17
+ Two migrations, if you use stored frames. Both are taken by the same
18
+ command, so run it once.
19
+
20
+ **1. Take the content pane migration.**
21
+
22
+ A pane can now hold words instead of a query (ADR 039). `janela_panes`
23
+ gains `kind`, `heading`, `body`, `link` and `partial`, and `model` and
24
+ `measure` become nullable:
25
+
26
+ ```bash
27
+ bin/rails janela:install:migrations
28
+ bin/rails db:migrate
29
+ ```
30
+
31
+ Every existing pane becomes `kind: "query"` and renders as it did.
32
+ Nothing else changes unless you add a content pane. If you want
33
+ analysts to place markup of your own, write it as partials under
34
+ `app/views/janela_content/`; each one is offered by name.
35
+
36
+ **2. The frame key migration comes with it.**
37
+
38
+ `janela_frames` gains a nullable `key` and a unique index on owner and
39
+ key (ADR 041). Existing frames keep a nil key and are unaffected. If you
40
+ keep a column in your own tables pointing at a frame, you can drop it
41
+ and find the frame with `Janela::Frame.for(record, :some_key)` instead.
42
+
43
+ **3. Check any CSS or JavaScript you wrote against a chart pane's canvas.**
44
+
45
+ A bar or line pane now renders its title as a visible `<figcaption>`
46
+ inside a `<figure>`, rather than only as the canvas's `aria-label`
47
+ (ADR 042). No migration; a markup change to check your own code against:
48
+
49
+ ```
50
+ Before: <canvas class="janela-pane janela-chart" role="img" aria-label="Revenue by Status">
51
+ After: <figure class="janela-pane">
52
+ <figcaption class="janela-chart-title">Revenue by Status</figcaption>
53
+ <canvas class="janela-chart" role="img" aria-labelledby="...">
54
+ </figure>
55
+ ```
56
+
57
+ `canvas.janela-chart` still selects the canvas. If you selected
58
+ `.janela-pane.janela-chart` as one element, or read a chart's title from
59
+ its `aria-label`, both need updating: `.janela-pane` is now the
60
+ `<figure>`, and the title is the figcaption's text, referenced by
61
+ `aria-labelledby`.
62
+
63
+ ## 0.7.0 to 0.8.0
64
+
65
+ Two steps, each only if it applies to you: one if you name a parent
66
+ controller, one if you wrote CSS against Janela's own pages. The doctor
67
+ also starts reporting things it always should have; that needs nothing
68
+ from you but a read.
69
+
70
+ **1. Set `Janela.parent_controller` in an initializer, if you set it.**
71
+
72
+ `Janela::ApplicationController` resolves its superclass once, the first
73
+ time the class loads. Naming a different one after that did nothing at
74
+ all, in silence, so your dashboards kept inheriting whatever was named
75
+ first and your authentication and `policy_scope` were not the ones you
76
+ wrote. It raises now rather than being ignored.
77
+
78
+ ```ruby
79
+ # config/initializers/janela.rb <- runs before anything can load it
80
+ Janela.parent_controller = "Admin::BaseController"
81
+ ```
82
+
83
+ Too late, all of them: `config.to_prepare`, `config.after_initialize`,
84
+ an `initializer` block ordered after `load_config_initializers`, and
85
+ anything that runs once the application is serving. The README's own
86
+ recipe for giving Janela a layout is a `to_prepare` block that loads the
87
+ controller, so if you use that, the initializer has to come first, and
88
+ it does.
89
+
90
+ Naming the controller Janela already inherits is still allowed, because
91
+ nothing is being asked for.
92
+
93
+ **2. Check any CSS you wrote against Janela's own pages.**
94
+
95
+ ADR 016 said "the class names are public API", which read literally
96
+ promised that `janela-card`, `janela-crumb`, `janela-flash`,
97
+ `janela-button`, `janela-form` and the rest of the chrome the engine
98
+ renders on *its own* pages would never be renamed without an entry here.
99
+ That was a promise made to nobody about markup only the engine draws,
100
+ and it made Janela's own pages harder to change than the library they
101
+ serve.
102
+
103
+ They are now scoped under `janela-page`, which only the engine's own
104
+ layout sets, so they cannot reach your pages, and they may change in any
105
+ release. Nothing is renamed in this release, so nothing breaks today. If
106
+ you styled Janela's own pages by targeting those class names, that
107
+ stylesheet is no longer standing on a contract.
108
+
109
+ What *is* the contract is unchanged: everything your own markup
110
+ contains, the grid scale and the pane primitives.
111
+ [docs/theming.md](docs/theming.md) lists all of it, the three custom
112
+ properties that carry `janela.css`, and what a theme is expected to
113
+ leave alone.
114
+
115
+ **What to expect from the doctor.** Two checks that could not fire for
116
+ an application whose `policy_scope` reaches for the signed in user now
117
+ do, so `bin/rails janela:doctor` may report findings that were always
118
+ true and never printed. `unscoped-reads` calls your `policy_scope`
119
+ rather than looking for the method, which catches a Pundit application
120
+ with no policy for `Janela::Frame` or `Janela::Snapshot`: that used to
121
+ pass the doctor and raise on every request.
122
+ `hardcoded-disallowed-predicates` drops to a warning and says a file
123
+ *mentions* a key rather than claiming it filters a model, because it
124
+ only ever grepped for the name.
125
+
15
126
  ## 0.6.0 to 0.7.0
16
127
 
17
128
  Janela has stopped guessing what may be read, in the two places it used
@@ -121,6 +121,7 @@ export default class extends Controller {
121
121
  const pane = this.paneTargets.find((each) => each.contains(event.target))
122
122
  const query = new URL(event.detail.url, window.location.origin)
123
123
  this.stripFilters(query)
124
+ this.keepFixedFilters(query, pane)
124
125
 
125
126
  // The query without filters first, since it is what this pane's URL is
126
127
  // rebuilt from on the next click as well as on the line below.
@@ -208,6 +209,20 @@ export default class extends Controller {
208
209
  }
209
210
  }
210
211
 
212
+ // A host's fixed filter is on a pane's base URL as where[...] (ADR 040), and
213
+ // a caller repointing the pane says nothing about it, the same as for the
214
+ // reader's filters. It carries over from the URL being replaced, so a
215
+ // repointed pane cannot drop out of the rows the host narrowed the frame to.
216
+ keepFixedFilters(url, pane) {
217
+ for (const key of [ ...url.searchParams.keys() ]) {
218
+ if (key.startsWith("where[")) url.searchParams.delete(key)
219
+ }
220
+ const previous = new URL(pane.dataset.janelaSrc, window.location.origin)
221
+ for (const [ key, value ] of previous.searchParams) {
222
+ if (key.startsWith("where[")) url.searchParams.append(key, value)
223
+ }
224
+ }
225
+
211
226
  // Sorted, keys and values both, so the browser serialises a selection the
212
227
  // same way every time and an unchanged src is never reloaded.
213
228
  writeFilters(url) {
@@ -69,6 +69,12 @@
69
69
  .janela-value-label { font-size: 0.85rem; opacity: 0.7; }
70
70
  .janela-value-number { font-size: 2rem; font-weight: 600; font-variant-numeric: tabular-nums; }
71
71
 
72
+ /* Words in a stored frame (ADR 039). A heading and paragraphs, spaced by the
73
+ same unit as everything else and otherwise left to the page. */
74
+ .janela-content > :first-child { margin-top: 0; }
75
+ .janela-content > :last-child { margin-bottom: 0; }
76
+ .janela-content-heading { font-size: 1.1rem; margin: 0 0 calc(var(--janela-space) * 2); }
77
+
72
78
  table.janela-pane { width: 100%; border-collapse: collapse; }
73
79
  table.janela-pane caption { text-align: left; font-weight: 600; margin-bottom: calc(var(--janela-space) * 2); }
74
80
  table.janela-pane td { padding: calc(var(--janela-space) * 1.5) 0; border-top: 1px solid var(--janela-line); }
@@ -85,8 +91,31 @@ table.janela-pane button {
85
91
  table.janela-pane button:hover { background: var(--janela-line); }
86
92
  table.janela-pane button[aria-pressed="true"] { background: var(--janela-accent); color: white; }
87
93
 
94
+ .janela-chart-title { font-weight: 600; margin-bottom: calc(var(--janela-space) * 2); }
88
95
  canvas.janela-chart { width: 100% !important; max-height: 20rem; }
89
96
 
97
+ /* A host whose own markup already says what a pane is puts this on any
98
+ ancestor, and the caption, the value's label and the chart's title stop
99
+ being drawn without leaving the accessibility tree. Hidden rather than
100
+ removed on purpose: each is its pane's accessible name (a chart's by
101
+ aria-labelledby since ADR 042), so display: none would land a screen
102
+ reader on a grid of numbers with nothing to say what they measure. The
103
+ rule ships here because that is easy to get wrong and every host would
104
+ otherwise write it (ADR 036, #26). */
105
+ .janela-own-headings table.janela-pane caption,
106
+ .janela-own-headings .janela-value-label,
107
+ .janela-own-headings .janela-chart-title {
108
+ position: absolute;
109
+ width: 1px;
110
+ height: 1px;
111
+ margin: -1px;
112
+ padding: 0;
113
+ overflow: hidden;
114
+ clip-path: inset(50%);
115
+ white-space: nowrap;
116
+ border: 0;
117
+ }
118
+
90
119
  /* Janela's own pages (ADR 013). Scoped to a class the engine's layout sets,
91
120
  so including this stylesheet changes nothing about a host's own pages. */
92
121
  .janela-page {
@@ -25,6 +25,13 @@ module Janela
25
25
  q.is_a?(ActionController::Parameters) ? q.permit!.to_h : {}
26
26
  end
27
27
 
28
+ # The host's fixed filter (ADR 040). Its own key, so nothing that writes
29
+ # the reader's q[...] can reach it.
30
+ def fixed_filters
31
+ where = params[:where]
32
+ where.is_a?(ActionController::Parameters) ? where.permit!.to_h : {}
33
+ end
34
+
28
35
  # Pundit defines policy_scope on the host's ApplicationController, which
29
36
  # this inherits from, so authorisation applies without Janela depending
30
37
  # on Pundit or being configured. A host that defines nothing is refused
@@ -5,21 +5,27 @@ module Janela
5
5
  before_action :set_frame
6
6
  before_action :set_pane, only: %i[show edit update destroy move_up move_down]
7
7
 
8
+ # A content pane has no query, so it has no URL to be fetched from.
8
9
  def show
9
- @query = @pane.query(filters: filters)
10
+ raise NotFound, "pane #{@pane.id} holds content, not a query" unless @pane.query?
11
+
12
+ @query = @pane.query(filters: filters, fixed: fixed_filters)
10
13
  @result = @query.result(on: janela_scope(@query.model))
11
14
  end
12
15
 
13
16
  # Two steps, because the engine's pages have no JavaScript to refresh one
14
17
  # select from another: the first picks a model, the second offers exactly
15
18
  # that model's measures and dimensions (ADR 018).
19
+ # Step one also offers words, and each partial the host wrote for them
20
+ # (ADR 039), in the same list as the models, since each is a choice of
21
+ # what the pane holds.
16
22
  def new
17
- @pane = @frame.panes.build(model: params[:model], renderer: "table", span: 1)
18
- @definition = @pane.model.present? ? Janela.definition!(@pane.model) : nil
23
+ @pane = build_pane(params[:model])
24
+ @definition = @pane.query? && @pane.model.present? ? Janela.definition!(@pane.model) : nil
19
25
  end
20
26
 
21
27
  def edit
22
- @definition = @pane.definition
28
+ @definition = @pane.definition if @pane.query?
23
29
  end
24
30
 
25
31
  def create
@@ -28,7 +34,7 @@ module Janela
28
34
  if @pane.save
29
35
  redirect_to edit_frame_path(@frame), notice: t("janela.panes.created")
30
36
  else
31
- @definition = @pane.model.present? ? Janela.definition!(@pane.model) : nil
37
+ @definition = @pane.query? && @pane.model.present? ? Janela.definition!(@pane.model) : nil
32
38
  render :new, status: :unprocessable_entity
33
39
  end
34
40
  end
@@ -37,7 +43,7 @@ module Janela
37
43
  if @pane.update(pane_params)
38
44
  redirect_to edit_frame_path(@frame), notice: t("janela.panes.updated")
39
45
  else
40
- @definition = @pane.definition
46
+ @definition = @pane.definition if @pane.query?
41
47
  render :edit, status: :unprocessable_entity
42
48
  end
43
49
  end
@@ -70,7 +76,16 @@ module Janela
70
76
  end
71
77
 
72
78
  def pane_params
73
- params.expect(pane: [ :model, :measure, :dimension, :renderer, :granularity, :limit, :span, :title ])
79
+ params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :span, :title,
80
+ :heading, :body, :link, :partial ])
81
+ end
82
+
83
+ def build_pane(choice)
84
+ case choice.to_s
85
+ when "text" then @frame.panes.build(kind: "text", span: 1)
86
+ when /\Apartial:(.+)\z/ then @frame.panes.build(kind: "partial", partial: $1, span: 1)
87
+ else @frame.panes.build(model: choice, renderer: "table", span: 1)
88
+ end
74
89
  end
75
90
  end
76
91
  end
@@ -8,7 +8,8 @@ module Janela
8
8
  renderer: params.fetch(:as, "table"),
9
9
  granularity: params[:granularity],
10
10
  limit: params[:limit],
11
- filters: filters
11
+ filters: filters,
12
+ fixed: fixed_filters
12
13
  )
13
14
 
14
15
  @result = @query.result(on: janela_scope(@query.model))
@@ -6,11 +6,20 @@ module Janela
6
6
  # renders filtered before any JavaScript runs.
7
7
  # charts: false renders a chart pane as its table, for a surface with no
8
8
  # chart runtime. The engine's own pages are the case (ADR 018).
9
- def janela_frame(frame = nil, charts: true, &block)
10
- return render("janela/frames/frame", frame: frame, filters: janela_page_filters, charts: charts) if frame
9
+ # where: is a filter the host fixes for this render, such as the record
10
+ # whose page this is. It goes into every pane's base URL rather than the
11
+ # frame's filters, so nothing the reader clicks can take it off (ADR 040).
12
+ def janela_frame(frame = nil, where: {}, charts: true, &block)
13
+ fixed = where.to_h.stringify_keys.sort.to_h
14
+ return render("janela/frames/frame", frame: frame, filters: janela_page_filters, fixed: fixed, charts: charts) if frame
11
15
 
12
- tag.div(data: { controller: "janela--frame", action: janela_frame_actions,
13
- janela__frame_filters_value: janela_page_filters.to_json }, &block)
16
+ begin
17
+ outer, @janela_fixed_filters = @janela_fixed_filters, fixed
18
+ tag.div(data: { controller: "janela--frame", action: janela_frame_actions,
19
+ janela__frame_filters_value: janela_page_filters.to_json }, &block)
20
+ ensure
21
+ @janela_fixed_filters = outer
22
+ end
14
23
  end
15
24
 
16
25
  # id: names the pane's frame instead of fingerprinting it from the query,
@@ -18,12 +27,12 @@ module Janela
18
27
  # limit control) keeps one stable frame for Turbo to reconcile into
19
28
  # rather than a different id every time the query changes (ADR 029).
20
29
  def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, id: nil)
21
- query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
30
+ query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit,
31
+ where: @janela_fixed_filters.presence }.compact
22
32
  base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
23
- src = janela_page_filters.empty? ? base : janela_routes.pane_path(model.model_name.route_key, measure, by, **query, q: janela_page_filters)
24
33
 
25
34
  turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
26
- src: src,
35
+ src: janela_with_page_filters(base),
27
36
  loading: :lazy,
28
37
  data: { janela__frame_target: "pane", janela_src: base }
29
38
  end
@@ -58,6 +67,16 @@ module Janela
58
67
  Janela.scope(controller, model)
59
68
  end
60
69
 
70
+ # The reader's filters appended after everything else in the base URL,
71
+ # which is how the frame controller builds the same URL. Rails sorts
72
+ # query parameters, which would put q before where, and a src spelled
73
+ # differently from the controller's is refetched as stale (#33).
74
+ def janela_with_page_filters(base)
75
+ return base if janela_page_filters.empty?
76
+
77
+ "#{base}#{base.include?("?") ? "&" : "?"}#{{ q: janela_page_filters }.to_query}"
78
+ end
79
+
61
80
  def janela_page_filters
62
81
  @janela_page_filters ||= begin
63
82
  q = request.query_parameters["q"]
@@ -15,6 +15,26 @@ module Janela
15
15
  validates :name, presence: true
16
16
  validates :columns, inclusion: { in: COLUMNS }
17
17
  validates :gap, inclusion: { in: GAPS }
18
+ # The shape of the symbol a host passes, so nothing that reads like a
19
+ # name or a path gets in (ADR 041).
20
+ validates :key, format: { with: /\A[a-z0-9_]+\z/ }, uniqueness: { scope: %i[owner_type owner_id] }, allow_nil: true
21
+
22
+ # The host's frame for this owner and key, created on first use (ADR 041).
23
+ # The block runs only when the frame is created, to set what a new frame
24
+ # starts as. Two requests creating it at once end with one row: the unique
25
+ # index refuses the second insert, and the find is asked again.
26
+ #
27
+ # Janela::Frame.for(account, :overview)
28
+ # Janela::Frame.for(queue, :analytics) { |frame| frame.name = "#{queue.name} analytics" }
29
+ def self.for(owner, key, &block)
30
+ key = key.to_s
31
+ find_or_create_by!(owner: owner, key: key) do |frame|
32
+ frame.name = key.humanize
33
+ block&.call(frame)
34
+ end
35
+ rescue ActiveRecord::RecordNotUnique
36
+ find_by!(owner: owner, key: key)
37
+ end
18
38
 
19
39
  # Positions are kept contiguous so that moving a pane has no gap to fall
20
40
  # into and a new pane's position is never a hole. Called after a pane is
@@ -10,15 +10,55 @@ module Janela
10
10
  # dashboard actually asks for. Any limit inside LIMITS is still valid.
11
11
  OFFERED_LIMITS = [ 5, 10, 20, 50, 100 ].freeze
12
12
 
13
+ # What a pane holds (ADR 039). A query is today's pane. Text is words an
14
+ # analyst writes, escaped. A partial is markup a host wrote in code, which
15
+ # an analyst places by name and hands the same words to.
16
+ KINDS = %w[query text partial].freeze
17
+ # Outside app/views/janela/ on purpose: a host view at an engine's path
18
+ # replaces the engine's own, and janela/panes/ holds the engine's forms.
19
+ CONTENT_PARTIALS = "janela_content"
20
+ PARTIAL_NAME = /\A[a-z0-9_]+\z/
21
+ # A path on this site: one slash, then not a second. A scheme or a
22
+ # protocol relative // would send a reader anywhere under the host's name.
23
+ SITE_PATH = %r{\A/(?!/)}
24
+
13
25
  belongs_to :frame
14
26
 
15
27
  before_validation :assign_position, on: :create
16
28
 
17
29
  validates :position, presence: true
18
- validates :measure, presence: true
30
+ validates :kind, inclusion: { in: KINDS }
19
31
  validates :span, inclusion: { in: SPANS }
20
32
  validates :limit, inclusion: { in: LIMITS }, allow_nil: true
21
- validate :declared_by_a_janela_block
33
+ validates :measure, presence: true, if: :query?
34
+ validate :declared_by_a_janela_block, if: :query?
35
+ validate :holds_no_query, unless: :query?
36
+ validates :heading, presence: true, if: -> { text? && body.blank? }
37
+ validates :link, format: { with: SITE_PATH, message: "must be a path on this site, starting with /" }, allow_blank: true
38
+ validate :names_a_content_partial, if: :partial?
39
+
40
+ # The partials a host has written for analysts to place, by name.
41
+ def self.content_partials
42
+ ActionController::Base.view_paths.flat_map do |path|
43
+ Dir.glob(File.join(path.to_s, CONTENT_PARTIALS, "_*.html.erb")).map { |file| File.basename(file, ".html.erb").delete_prefix("_") }
44
+ end.select { |name| name.match?(PARTIAL_NAME) }.uniq.sort
45
+ end
46
+
47
+ def query?
48
+ kind == "query"
49
+ end
50
+
51
+ def text?
52
+ kind == "text"
53
+ end
54
+
55
+ def partial?
56
+ kind == "partial"
57
+ end
58
+
59
+ def partial_path
60
+ "#{CONTENT_PARTIALS}/#{partial}"
61
+ end
22
62
 
23
63
  # The DOM id is the row, not the query it runs: two rows in one frame may
24
64
  # show the same measure by the same dimension, and a fingerprint of the
@@ -30,15 +70,17 @@ module Janela
30
70
  # renderer: overrides what the row asked for, because a renderer is a
31
71
  # viewing choice and a surface without a chart runtime shows a table
32
72
  # instead (ADR 018).
33
- def query(filters: {}, renderer: self.renderer)
73
+ def query(filters: {}, fixed: {}, renderer: self.renderer)
34
74
  Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
35
- renderer: renderer, granularity: granularity, limit: limit, filters: filters, title: title)
75
+ renderer: renderer, granularity: granularity, limit: limit, filters: filters, fixed: fixed,
76
+ title: title)
36
77
  end
37
78
 
38
79
  # The row's own words, for a list or a heading. Built from the columns
39
80
  # rather than from a query, because an editing page has to render even if
40
81
  # a janela block has since lost the dimension this row names.
41
82
  def label
83
+ return content_label unless query?
42
84
  return title if title.present?
43
85
 
44
86
  dimension.present? ? "#{measure.humanize} by #{dimension.humanize}" : measure.to_s.humanize
@@ -85,6 +127,27 @@ module Janela
85
127
  true
86
128
  end
87
129
 
130
+ def content_label
131
+ return heading if heading.present?
132
+ return partial.humanize if partial?
133
+
134
+ body.to_s.truncate(40)
135
+ end
136
+
137
+ # A row is one thing or the other, so the database never holds half a
138
+ # query that nothing will run.
139
+ def holds_no_query
140
+ %i[model measure dimension granularity].each do |column|
141
+ errors.add(column, "belongs to a query pane, not a #{kind} one") if self[column].present?
142
+ end
143
+ end
144
+
145
+ def names_a_content_partial
146
+ return if partial.to_s.match?(PARTIAL_NAME) && self.class.content_partials.include?(partial)
147
+
148
+ errors.add(:partial, "must name a partial in app/views/#{CONTENT_PARTIALS}/")
149
+ end
150
+
88
151
  def assign_position
89
152
  self.position ||= (frame&.panes&.maximum(:position) || 0) + 1
90
153
  end