janela 0.6.0 → 0.8.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 +42 -0
- data/README.md +29 -10
- data/UPGRADING.md +167 -0
- data/app/assets/stylesheets/janela.css +21 -0
- data/app/controllers/janela/application_controller.rb +3 -2
- data/app/helpers/janela/frames_helper.rb +4 -2
- data/app/jobs/janela/snapshot_job.rb +47 -6
- data/app/models/janela/snapshot.rb +11 -2
- data/db/migrate/20260921000001_add_owner_to_janela_snapshots.rb +9 -0
- data/docs/decisions/009-snapshots.md +1 -1
- data/docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md +157 -0
- data/docs/decisions/033-a-snapshot-is-told-who-owns-it.md +151 -0
- data/docs/decisions/034-janela-will-not-freeze-a-scope-the-host-has-not-named.md +238 -0
- data/docs/decisions/035-a-check-does-what-janela-does-or-says-what-it-saw.md +262 -0
- data/docs/decisions/036-janela-publishes-what-a-theme-may-target.md +171 -0
- data/docs/decisions/037-what-1-0-means.md +172 -0
- data/docs/decisions/INDEX.md +18 -10
- data/docs/multi-tenancy.md +87 -18
- data/docs/roadmap.md +97 -0
- data/docs/theming.md +177 -0
- data/lib/janela/definition.rb +8 -1
- data/lib/janela/doctor.rb +169 -29
- data/lib/janela/model.rb +6 -1
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +74 -3
- metadata +27 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a28af383164e7bc1176c8b41bcff4d4ee384bbdaac7313b66a781c5c047a4fb8
|
|
4
|
+
data.tar.gz: 7611b5e28e04d9dc3cf45a940eba1aae33e2b80beb708f3a24b7ecc12a650819
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b32263ebc50a9d33ca95a9629bde4a58937370ea70df0fe17c58d651a0f87953aeb668dc363933c0aaa5e36a2b4bd663ffbdf4ea670cf5eec3dd5e3f7ee380d
|
|
7
|
+
data.tar.gz: 7651430b673d289b5bfa1a650c66351d580714c542cba06ec046cac4236dde21079c5debeddc9d27439442596b8a317aa60adccda1d1c3c9f1216b60a0d73f54
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ 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.8.0] - 2026-09-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `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).
|
|
13
|
+
- `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).
|
|
14
|
+
- `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).
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **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).
|
|
19
|
+
- **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).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- 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).
|
|
24
|
+
|
|
25
|
+
- `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).
|
|
26
|
+
- `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).
|
|
27
|
+
- `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).
|
|
28
|
+
- 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).
|
|
29
|
+
- `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).
|
|
30
|
+
|
|
31
|
+
## [0.7.0] - 2026-09-22
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `rails janela:doctor` reports snapshots stored with no owner when your policy filters snapshots by owner, as `snapshots-nobody-will-see`, a warning naming how many. Such a snapshot is stored and unreachable: the row is there, a link to it answers 404, and nothing says why. Usually they are snapshots taken before the owner column existed, which is what an upgrade produces. ADR 033 judged a check here a thinner case than the frame's, because a caller assigns a snapshot's owner in its own Ruby, and said it was worth revisiting if it bit: it bit four times in this repository's own tests and once on the live demo within an afternoon of the column landing (#49).
|
|
36
|
+
- A snapshot carries an owner, the same nullable polymorphic one a frame has, so a multi tenant application's policy has the same column to filter a snapshot on that it already has for a frame. `Janela::Snapshot.take(name:, owner:)` assigns it and `Janela::SnapshotJob` carries it across the queue through its GlobalID. Janela reads nothing from it, exactly as with a frame. It is an argument rather than a method on your controller because a snapshot is never taken in a request: there is no create route and no form, only `take` called from a job, a task or a console, where a hook reaching for the current tenant would work in a console and return nil in the job that is the point of the feature. Needs `rails janela:install:migrations` and a migrate; existing snapshots keep a nil owner and nothing breaks if you ignore it. The owner says who a snapshot belongs to and nothing about the numbers inside it, which is the separate question the `SnapshotJob` entry above answers (ADR 033, #32).
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **Breaking.** `Janela::SnapshotJob` will not freeze a scope you have not named. It took every pane over the model's default scope, which ADR 009 chose deliberately because a relation cannot be serialised into a job. That is your tenant's rows if your tenancy is enforced on your models, and every row of every model if your scoping lives in your policies, and nothing reachable from a job can tell which application it is in: measured against a policy scoped host, the same application showed $150.00 on the live pane and published $375.00 from the snapshot beside it, under that tenant's own name, answered 200 with nothing in the log. A live unscoped read is wrong once, on a screen, to somebody already signed in; a snapshot freezes it into a row and serves it at an address to the external audience snapshots exist for. The job now asks one question, `scope_for(model)`, and refuses to answer it for you. A host whose tenancy is on its models, or who has one tenant, passes `scope: :model_default` and carries on. A host whose scoping is in its policies answers in Ruby, where a relation is still a relation, by subclassing the job and overriding `scope_for`, with `name`, `owner` and `filters` readable beside it so nothing has to override `perform`; that replaces the old advice to write a job around `Snapshot.take` from scratch. Neither answer raises `Janela::Unscoped`, the same refusal a request gets. `Snapshot.take` and `take.pane`'s `on:` are unchanged, because those are calls you write in your own Ruby. Note before deploying: a job already on the queue was serialised without `scope:` and will raise when it performs, which no diff will show you (ADR 034, #47).
|
|
41
|
+
|
|
42
|
+
- **Breaking.** Janela refuses to read a model it has not been told how to scope. `janela_scope` asked the host's controller for `policy_scope` and fell back to `model.all` when there was none, so an application using a different authorisation library, or none at all, got every row of every model on a dashboard with nothing said: a request that should have been a refusal answered 200 carrying numbers its reader may have had no right to, and no line mentioning scope reached the log. It now raises `Janela::Unscoped`, naming the method to define and the class to define it on. An application with nothing to hide answers once, `private def policy_scope(model) = model.all`, which is a sentence worth writing rather than inheriting by omission: "one tenant" and "everyone may read every row" are not the same claim. A host using Pundit is unaffected, including one missing a policy, because Pundit already raises on that. `rails janela:doctor` reports the absence as `unscoped-reads` at error severity, and unlike `unauthenticated-endpoints` it does not have to hedge, since the method is either defined or it is not (ADR 032, #7).
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- A frame rendered in a host's own page read every row when `policy_scope` was a private controller method, which is the shape `docs/multi-tenancy.md` teaches and the shape Pundit's own has. Janela asked the view whether the host had defined a scope, where its own controllers ask the controller, and a view cannot see a private controller method: the same application was scoped on Janela's pages and silently unscoped on its own, with no error and nothing in the log. A host using Pundit was unaffected, because `Pundit::Helper` separately defines a view side copy. If you embed `janela_frame` or `janela_pane` in your own views and your scope narrows what a pane counts, those numbers were too high and are now correct (#46).
|
|
47
|
+
|
|
8
48
|
## [0.6.0] - 2026-09-20
|
|
9
49
|
|
|
10
50
|
### Added
|
|
@@ -152,6 +192,8 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
152
192
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
153
193
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
154
194
|
|
|
195
|
+
[0.8.0]: https://github.com/retail-tasker/janela/releases/tag/v0.8.0
|
|
196
|
+
[0.7.0]: https://github.com/retail-tasker/janela/releases/tag/v0.7.0
|
|
155
197
|
[0.6.0]: https://github.com/retail-tasker/janela/releases/tag/v0.6.0
|
|
156
198
|
[0.5.0]: https://github.com/retail-tasker/janela/releases/tag/v0.5.0
|
|
157
199
|
[0.4.1]: https://github.com/retail-tasker/janela/releases/tag/v0.4.1
|
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.
|
|
30
|
+
gem "janela", "~> 0.8"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
@@ -348,13 +348,15 @@ The model is its route key (`orders`, `sales_orders`), then the measure, then op
|
|
|
348
348
|
A snapshot freezes the results of several panes at one instant, under one set of filters, so an audience sees exactly what was signed off while the live dashboard stays editable. Results are stored, not HTML; a stored pane can still be drawn as a table or a chart. It needs the same migrations frames do.
|
|
349
349
|
|
|
350
350
|
```ruby
|
|
351
|
-
Janela::Snapshot.take(name: "September 2026", filters: { status_eq: "paid" }) do |take|
|
|
351
|
+
Janela::Snapshot.take(name: "September 2026", owner: Current.account, filters: { status_eq: "paid" }) do |take|
|
|
352
352
|
take.pane Order, :revenue, on: policy_scope(Order)
|
|
353
353
|
take.pane Order, :revenue, by: :status, on: policy_scope(Order)
|
|
354
354
|
take.pane Order, :revenue, by: :placed_on, granularity: :week
|
|
355
355
|
end
|
|
356
356
|
```
|
|
357
357
|
|
|
358
|
+
`owner:` is optional and Janela reads nothing from it: it is there so your policy has the same column to filter a snapshot on that it has for a frame. It is an argument rather than a controller hook because a snapshot is never taken in a request, so there is nothing to ask (ADR 033).
|
|
359
|
+
|
|
358
360
|
Render a stored pane the same way you render a live one:
|
|
359
361
|
|
|
360
362
|
```erb
|
|
@@ -390,7 +392,22 @@ Do not point Janela at your **application** layout. Janela is an isolated engine
|
|
|
390
392
|
|
|
391
393
|
Stored panes are static by nature: no filter buttons, charts ignore clicks, and the URL says *as of*: `/dashboards/snapshots/42/orders/revenue/status`. Request filters are ignored because the snapshot's were fixed when it was taken.
|
|
392
394
|
|
|
393
|
-
`Janela::SnapshotJob
|
|
395
|
+
`Janela::SnapshotJob` takes one from serialisable arguments so you can schedule it with whatever runs your jobs, carrying the owner across the queue through its GlobalID. It asks you one question and will not answer it for you: what rows does each pane freeze?
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
Janela::SnapshotJob.perform_later(name: "September 2026", owner: Current.account, scope: :model_default,
|
|
399
|
+
panes: [ { "model" => "orders", "measure" => "revenue", "by" => "status" } ])
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`scope: :model_default` says each pane is taken over its model's default scope. That is already your tenant's rows if your tenancy is enforced on the models themselves, through acts_as_tenant, a `default_scope`, a connection or a schema. If your scoping lives in your policies instead, it is every row of every model, and no symbol can carry the relation you want, so answer in Ruby and schedule your own job:
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
class TenantSnapshotJob < Janela::SnapshotJob
|
|
406
|
+
private def scope_for(model) = model.where(account: owner)
|
|
407
|
+
end
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
`scope_for` is the whole extension point. Override it and the `scope:` argument is not consulted, because the method that reads it is the one you replaced; `name`, `owner` and `filters` are readable beside it, so you never have to override `perform`. Pass neither and the job raises `Janela::Unscoped` rather than freezing a scope nobody chose, which is the same refusal `policy_scope` gets in a request (ADR 034).
|
|
394
411
|
|
|
395
412
|
Who may see a snapshot is your decision. Stored panes go through the same controllers as live ones, so your authentication applies; an external audience gets a page you build over `janela_snapshot_pane` behind whatever share tokens you already trust. ADR 009 has the reasoning.
|
|
396
413
|
|
|
@@ -411,9 +428,9 @@ It is *prepended* so it runs before any filter on your `ApplicationController` t
|
|
|
411
428
|
|
|
412
429
|
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.`.
|
|
413
430
|
|
|
414
|
-
Scoping is automatic when you use Pundit: `Janela::ApplicationController` calls `policy_scope(model)` if your `ApplicationController` defines it, and
|
|
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. 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.
|
|
415
432
|
|
|
416
|
-
**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
|
|
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 what rows a scheduled snapshot freezes.
|
|
417
434
|
|
|
418
435
|
### The pages Janela serves
|
|
419
436
|
|
|
@@ -444,9 +461,11 @@ bin/rails janela:doctor
|
|
|
444
461
|
Reads your application and lists what still needs doing: identifiers left over
|
|
445
462
|
from an earlier version, Stimulus controllers you have not registered, tables
|
|
446
463
|
you have not migrated, a `through:` dimension whose associated model does not
|
|
447
|
-
allowlist the attribute, a
|
|
448
|
-
|
|
449
|
-
|
|
464
|
+
allowlist the attribute, a controller that defines no `policy_scope` at all, a
|
|
465
|
+
policy that scopes frames by an owner you never supply, snapshots stored with
|
|
466
|
+
no owner under a policy that filters on one, and whether the engine is mounted
|
|
467
|
+
and authenticated. It exits non-zero when it finds an error, so it works in
|
|
468
|
+
CI. It only reads and reports.
|
|
450
469
|
|
|
451
470
|
Every finding names the check that produced it:
|
|
452
471
|
|
|
@@ -478,13 +497,13 @@ Janela ships the load-bearing core of a BI tool and nothing else. The reasoning
|
|
|
478
497
|
- **Querying rides on [Ransack](https://github.com/activerecord-hackery/ransack)'s association-path traversal.** Janela does not invent a query language.
|
|
479
498
|
- **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.
|
|
480
499
|
- **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.
|
|
481
|
-
- **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.
|
|
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. The job will not freeze a scope you have not named (ADR 034).
|
|
482
501
|
|
|
483
502
|
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.
|
|
484
503
|
|
|
485
504
|
## Status
|
|
486
505
|
|
|
487
|
-
**v0.
|
|
506
|
+
**v0.8.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, 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. [Where Janela Is Going](docs/roadmap.md) 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).
|
|
488
507
|
|
|
489
508
|
## Development
|
|
490
509
|
|
data/UPGRADING.md
CHANGED
|
@@ -12,6 +12,173 @@ bin/rails janela:doctor
|
|
|
12
12
|
|
|
13
13
|
It reads your application and lists what still needs changing.
|
|
14
14
|
|
|
15
|
+
## 0.7.0 to 0.8.0
|
|
16
|
+
|
|
17
|
+
Two steps, each only if it applies to you: one if you name a parent
|
|
18
|
+
controller, one if you wrote CSS against Janela's own pages. The doctor
|
|
19
|
+
also starts reporting things it always should have; that needs nothing
|
|
20
|
+
from you but a read.
|
|
21
|
+
|
|
22
|
+
**1. Set `Janela.parent_controller` in an initializer, if you set it.**
|
|
23
|
+
|
|
24
|
+
`Janela::ApplicationController` resolves its superclass once, the first
|
|
25
|
+
time the class loads. Naming a different one after that did nothing at
|
|
26
|
+
all, in silence, so your dashboards kept inheriting whatever was named
|
|
27
|
+
first and your authentication and `policy_scope` were not the ones you
|
|
28
|
+
wrote. It raises now rather than being ignored.
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# config/initializers/janela.rb <- runs before anything can load it
|
|
32
|
+
Janela.parent_controller = "Admin::BaseController"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Too late, all of them: `config.to_prepare`, `config.after_initialize`,
|
|
36
|
+
an `initializer` block ordered after `load_config_initializers`, and
|
|
37
|
+
anything that runs once the application is serving. The README's own
|
|
38
|
+
recipe for giving Janela a layout is a `to_prepare` block that loads the
|
|
39
|
+
controller, so if you use that, the initializer has to come first, and
|
|
40
|
+
it does.
|
|
41
|
+
|
|
42
|
+
Naming the controller Janela already inherits is still allowed, because
|
|
43
|
+
nothing is being asked for.
|
|
44
|
+
|
|
45
|
+
**2. Check any CSS you wrote against Janela's own pages.**
|
|
46
|
+
|
|
47
|
+
ADR 016 said "the class names are public API", which read literally
|
|
48
|
+
promised that `janela-card`, `janela-crumb`, `janela-flash`,
|
|
49
|
+
`janela-button`, `janela-form` and the rest of the chrome the engine
|
|
50
|
+
renders on *its own* pages would never be renamed without an entry here.
|
|
51
|
+
That was a promise made to nobody about markup only the engine draws,
|
|
52
|
+
and it made Janela's own pages harder to change than the library they
|
|
53
|
+
serve.
|
|
54
|
+
|
|
55
|
+
They are now scoped under `janela-page`, which only the engine's own
|
|
56
|
+
layout sets, so they cannot reach your pages, and they may change in any
|
|
57
|
+
release. Nothing is renamed in this release, so nothing breaks today. If
|
|
58
|
+
you styled Janela's own pages by targeting those class names, that
|
|
59
|
+
stylesheet is no longer standing on a contract.
|
|
60
|
+
|
|
61
|
+
What *is* the contract is unchanged: everything your own markup
|
|
62
|
+
contains, the grid scale and the pane primitives.
|
|
63
|
+
[docs/theming.md](docs/theming.md) lists all of it, the three custom
|
|
64
|
+
properties that carry `janela.css`, and what a theme is expected to
|
|
65
|
+
leave alone.
|
|
66
|
+
|
|
67
|
+
**What to expect from the doctor.** Two checks that could not fire for
|
|
68
|
+
an application whose `policy_scope` reaches for the signed in user now
|
|
69
|
+
do, so `bin/rails janela:doctor` may report findings that were always
|
|
70
|
+
true and never printed. `unscoped-reads` calls your `policy_scope`
|
|
71
|
+
rather than looking for the method, which catches a Pundit application
|
|
72
|
+
with no policy for `Janela::Frame` or `Janela::Snapshot`: that used to
|
|
73
|
+
pass the doctor and raise on every request.
|
|
74
|
+
`hardcoded-disallowed-predicates` drops to a warning and says a file
|
|
75
|
+
*mentions* a key rather than claiming it filters a model, because it
|
|
76
|
+
only ever grepped for the name.
|
|
77
|
+
|
|
78
|
+
## 0.6.0 to 0.7.0
|
|
79
|
+
|
|
80
|
+
Janela has stopped guessing what may be read, in the two places it used
|
|
81
|
+
to default to every row: a pane in a request (ADR 032) and a scheduled
|
|
82
|
+
snapshot (ADR 034). Three steps, and most applications have already
|
|
83
|
+
taken the first.
|
|
84
|
+
|
|
85
|
+
**1. Say what may be read, if you have not.**
|
|
86
|
+
|
|
87
|
+
If your `ApplicationController` defines `policy_scope`, nothing changes.
|
|
88
|
+
If you use Pundit, nothing changes. If neither is true, every dashboard,
|
|
89
|
+
pane and inline frame now raises `Janela::Unscoped` where it previously
|
|
90
|
+
totalled every row:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class ApplicationController < ActionController::Base
|
|
94
|
+
+ private
|
|
95
|
+
+ def policy_scope(model) = model.all
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
That line is an assertion, not a formality: it says every visitor who can
|
|
100
|
+
reach a dashboard may read every row of every model on it. "One tenant"
|
|
101
|
+
and "nothing here is worth hiding from staff" are different claims, and
|
|
102
|
+
only the second one licenses `model.all`. If it is not true of your
|
|
103
|
+
application, return something narrower.
|
|
104
|
+
`docs/multi-tenancy.md` has the wiring for acts_as_tenant, CanCanCan and
|
|
105
|
+
the rest.
|
|
106
|
+
|
|
107
|
+
Find it before a visitor does:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
bin/rails janela:doctor
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`unscoped-reads` reports the absence as an error and prints the line to
|
|
114
|
+
add.
|
|
115
|
+
|
|
116
|
+
**2. Take the snapshot owner migration, if you use snapshots.**
|
|
117
|
+
|
|
118
|
+
`janela_snapshots` gains a nullable polymorphic owner so your policy can
|
|
119
|
+
filter a snapshot the way it already filters a frame:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
bin/rails janela:install:migrations
|
|
123
|
+
bin/rails db:migrate
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Existing snapshots keep a nil owner and keep working. Pass `owner:` when
|
|
127
|
+
you take new ones, and add `Janela::Snapshot` to whatever your policy
|
|
128
|
+
already does for `Janela::Frame`:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
- Janela::Snapshot.take(name: "September") { |take| ... }
|
|
132
|
+
+ Janela::Snapshot.take(name: "September", owner: Current.account) { |take| ... }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The owner says who a snapshot belongs to. What the numbers inside it
|
|
136
|
+
cover is the next step.
|
|
137
|
+
|
|
138
|
+
**3. Tell `Janela::SnapshotJob` what rows to freeze, if you schedule
|
|
139
|
+
snapshots.**
|
|
140
|
+
|
|
141
|
+
The job used to take every pane over the model's default scope, which is
|
|
142
|
+
your tenant's rows if your tenancy is enforced on your models and every
|
|
143
|
+
row if it lives in your policies. Janela cannot tell which application it
|
|
144
|
+
is in, so it has stopped choosing (ADR 034). It now raises
|
|
145
|
+
`Janela::Unscoped` unless you answer.
|
|
146
|
+
|
|
147
|
+
If your tenancy is on your models, or you have one tenant, say so:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
- Janela::SnapshotJob.perform_later(name: "September", panes: [...])
|
|
151
|
+
+ Janela::SnapshotJob.perform_later(name: "September", scope: :model_default, panes: [...])
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
If your scoping lives in your policies, `model.all` is every row and no
|
|
155
|
+
symbol can carry the relation you want. Answer in Ruby instead, and
|
|
156
|
+
schedule your own job:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
class TenantSnapshotJob < Janela::SnapshotJob
|
|
160
|
+
private def scope_for(model) = model.where(account: owner)
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Override `scope_for` and the `scope:` argument is not consulted, because
|
|
165
|
+
the method that reads it is the one you replaced. `name`, `owner` and
|
|
166
|
+
`filters` are readable beside it, so there is no need to override
|
|
167
|
+
`perform`. This replaces the old advice to write a job around
|
|
168
|
+
`Snapshot.take` from scratch, which still works and is now one method
|
|
169
|
+
longer than it needs to be.
|
|
170
|
+
|
|
171
|
+
**Drain the queue, or expect the retries.** A `SnapshotJob` enqueued
|
|
172
|
+
before you deploy was serialised without `scope:` and will raise when it
|
|
173
|
+
performs. Nothing in the diff shows you this. Let the queue empty before
|
|
174
|
+
deploying, or re-enqueue what fails afterwards.
|
|
175
|
+
|
|
176
|
+
**What did not change.** `Order.janela.query(:revenue)` called from your
|
|
177
|
+
own Ruby still runs over `Order.all`, because you wrote that call and the
|
|
178
|
+
scope was yours to choose. Only what Janela decides on your behalf inside
|
|
179
|
+
a request has stopped guessing. Frames, pane rows and snapshots are read
|
|
180
|
+
through the same scope they always were.
|
|
181
|
+
|
|
15
182
|
## 0.5.0 to 0.6.0
|
|
16
183
|
|
|
17
184
|
How single table inheritance is handled changed (ADR 031). Nothing here
|
|
@@ -87,6 +87,27 @@ table.janela-pane button[aria-pressed="true"] { background: var(--janela-accent)
|
|
|
87
87
|
|
|
88
88
|
canvas.janela-chart { width: 100% !important; max-height: 20rem; }
|
|
89
89
|
|
|
90
|
+
/* A host whose own markup already says what a pane is puts this on any
|
|
91
|
+
ancestor, and the caption and the single value's label stop being drawn
|
|
92
|
+
without leaving the accessibility tree. Hidden rather than removed on
|
|
93
|
+
purpose: a caption is the table's accessible name, so display: none would
|
|
94
|
+
land a screen reader on a grid of numbers with nothing to say what they
|
|
95
|
+
measure. The rule ships here because that is easy to get wrong and every
|
|
96
|
+
host would otherwise write it (ADR 036, #26). A chart pane needs nothing:
|
|
97
|
+
its title was only ever an aria-label. */
|
|
98
|
+
.janela-own-headings table.janela-pane caption,
|
|
99
|
+
.janela-own-headings .janela-value-label {
|
|
100
|
+
position: absolute;
|
|
101
|
+
width: 1px;
|
|
102
|
+
height: 1px;
|
|
103
|
+
margin: -1px;
|
|
104
|
+
padding: 0;
|
|
105
|
+
overflow: hidden;
|
|
106
|
+
clip-path: inset(50%);
|
|
107
|
+
white-space: nowrap;
|
|
108
|
+
border: 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
90
111
|
/* Janela's own pages (ADR 013). Scoped to a class the engine's layout sets,
|
|
91
112
|
so including this stylesheet changes nothing about a host's own pages. */
|
|
92
113
|
.janela-page {
|
|
@@ -27,9 +27,10 @@ module Janela
|
|
|
27
27
|
|
|
28
28
|
# Pundit defines policy_scope on the host's ApplicationController, which
|
|
29
29
|
# this inherits from, so authorisation applies without Janela depending
|
|
30
|
-
# on Pundit or being configured.
|
|
30
|
+
# on Pundit or being configured. A host that defines nothing is refused
|
|
31
|
+
# rather than answered with every row (ADR 032).
|
|
31
32
|
def janela_scope(model)
|
|
32
|
-
|
|
33
|
+
Janela.scope(self, model)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
# A host that scopes frames by owner would hide a frame created without
|
|
@@ -51,9 +51,11 @@ module Janela
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# A frame rendered inline runs its queries in the host's own request, so
|
|
54
|
-
# the same
|
|
54
|
+
# the same scope the engine's controllers apply is applied here. The
|
|
55
|
+
# controller is asked, not the view, and the question itself lives in
|
|
56
|
+
# one place so the two can never disagree again (#46, ADR 032).
|
|
55
57
|
def janela_scope(model)
|
|
56
|
-
|
|
58
|
+
Janela.scope(controller, model)
|
|
57
59
|
end
|
|
58
60
|
|
|
59
61
|
def janela_page_filters
|
|
@@ -1,18 +1,59 @@
|
|
|
1
1
|
module Janela
|
|
2
2
|
# Takes a snapshot from serialisable arguments so a host can schedule it
|
|
3
|
-
# with whatever runs its jobs.
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# with whatever runs its jobs.
|
|
4
|
+
#
|
|
5
|
+
# What rows each pane freezes is the host's answer rather than Janela's. A
|
|
6
|
+
# host whose tenancy is enforced on its models says so with
|
|
7
|
+
# scope: :model_default; a host whose tenancy lives in its policies
|
|
8
|
+
# subclasses this and overrides scope_for, because a class name crosses the
|
|
9
|
+
# queue where a relation cannot (ADR 034).
|
|
6
10
|
class SnapshotJob < ActiveJob::Base
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
# ActiveJob cannot serialise a relation, which is why on: is not an
|
|
12
|
+
# argument here, but it serialises a record through its GlobalID, so an
|
|
13
|
+
# owner crosses the queue boundary without anything new (ADR 033).
|
|
14
|
+
def perform(name:, panes:, owner: nil, filters: {}, scope: nil)
|
|
15
|
+
@name, @owner, @filters, @scope = name, owner, filters, scope&.to_sym
|
|
16
|
+
|
|
17
|
+
Snapshot.take(name: name, owner: owner, filters: filters) do |take|
|
|
9
18
|
panes.each do |pane|
|
|
10
19
|
pane = pane.to_h.stringify_keys
|
|
11
20
|
model = Janela.definition!(pane.fetch("model")).model
|
|
12
|
-
take.pane(model, pane.fetch("measure").to_sym,
|
|
21
|
+
take.pane(model, pane.fetch("measure").to_sym, on: scope_for(model),
|
|
13
22
|
by: pane["by"]&.to_sym, granularity: pane["granularity"], limit: pane["limit"])
|
|
14
23
|
end
|
|
15
24
|
end
|
|
16
25
|
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
# What the job was told, so a subclass answering scope_for never has to
|
|
29
|
+
# override perform or reach into ActiveJob's arguments to find it.
|
|
30
|
+
attr_reader :name, :owner, :filters
|
|
31
|
+
|
|
32
|
+
# The one question this job asks, and the one it will not answer on a
|
|
33
|
+
# host's behalf: what rows does a pane freeze? A model's default scope
|
|
34
|
+
# is the tenant's rows when tenancy is enforced on the models, and every
|
|
35
|
+
# row when it lives in a policy, and nothing reachable from a job can
|
|
36
|
+
# tell which application this is. Unanswered it refuses, because the
|
|
37
|
+
# wrong answer here is frozen into a row and published (ADR 034).
|
|
38
|
+
def scope_for(model)
|
|
39
|
+
raise Unscoped, unanswered if @scope.nil?
|
|
40
|
+
raise ArgumentError, "#{@scope.inspect} is not a scope Janela knows. Pass :model_default, " \
|
|
41
|
+
"or override scope_for in a subclass of Janela::SnapshotJob." unless @scope == :model_default
|
|
42
|
+
|
|
43
|
+
model.all
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unanswered
|
|
47
|
+
"Janela::SnapshotJob has not been told what rows to freeze. If your tenancy is enforced " \
|
|
48
|
+
"on your models, a model's default scope is already the rows you mean, and saying so is " \
|
|
49
|
+
"the whole of it:\n\n" \
|
|
50
|
+
" Janela::SnapshotJob.perform_later(name: \"September\", scope: :model_default, panes: [...])\n\n" \
|
|
51
|
+
"If your scoping lives in your policies instead, that is every row of every model. " \
|
|
52
|
+
"Answer in Ruby, where a relation is still a relation, and schedule your own job:\n\n" \
|
|
53
|
+
" class TenantSnapshotJob < Janela::SnapshotJob\n" \
|
|
54
|
+
" private def scope_for(model) = model.where(account: owner)\n" \
|
|
55
|
+
" end\n\n" \
|
|
56
|
+
"docs/multi-tenancy.md has the wiring."
|
|
57
|
+
end
|
|
17
58
|
end
|
|
18
59
|
end
|
|
@@ -4,15 +4,24 @@ module Janela
|
|
|
4
4
|
# live dashboard stays editable (ADR 009). Results are stored, not HTML; the
|
|
5
5
|
# renderer is chosen by whoever shows a stored pane.
|
|
6
6
|
class Snapshot < ActiveRecord::Base
|
|
7
|
+
# Janela sets what it is handed and reads nothing from it. The column
|
|
8
|
+
# exists so a multi tenant host's policy has something to filter a
|
|
9
|
+
# snapshot on, the same as a frame's (ADR 033).
|
|
10
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
11
|
+
|
|
7
12
|
attribute :filters, default: -> { {} }
|
|
8
13
|
attribute :panes, default: -> { [] }
|
|
9
14
|
|
|
10
15
|
validates :name, :taken_at, presence: true
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
# The owner is an argument rather than something asked of a controller,
|
|
18
|
+
# because a snapshot is never taken in a request: this runs in a job, a
|
|
19
|
+
# task, a console or a host's own code, and the caller is the only thing
|
|
20
|
+
# that knows the answer (ADR 033).
|
|
21
|
+
def self.take(name:, owner: nil, filters: {}, taken_at: Time.current)
|
|
13
22
|
taking = Taking.new(filters.to_h.stringify_keys)
|
|
14
23
|
yield taking
|
|
15
|
-
create!(name: name, taken_at: taken_at, filters: taking.filters, panes: taking.panes)
|
|
24
|
+
create!(name: name, owner: owner, taken_at: taken_at, filters: taking.filters, panes: taking.panes)
|
|
16
25
|
end
|
|
17
26
|
|
|
18
27
|
def stored_result(query)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddOwnerToJanelaSnapshots < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# Janela never reads the owner. It is here so a host's Pundit Scope has
|
|
4
|
+
# something to filter a snapshot on, the same as a frame's (ADR 033).
|
|
5
|
+
# Nullable, because a snapshot nobody owns is invisible to a policy that
|
|
6
|
+
# filters on one, which is the safe direction to fail.
|
|
7
|
+
add_reference :janela_snapshots, :owner, polymorphic: true, null: true
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Date: 2026-09-15
|
|
3
3
|
Status: Accepted
|
|
4
4
|
Related: ADR 001, ADR 002, ADR 005, ADR 008, ADR 012
|
|
5
|
-
Superseded in part by: ADR 012
|
|
5
|
+
Superseded in part by: ADR 012, ADR 033, ADR 034
|
|
6
6
|
Triggers:
|
|
7
7
|
- publishing a dashboard or pane for an audience that must not see live data or slicers
|
|
8
8
|
- adding a database table, migration or model to the engine
|