janela 0.5.0 → 0.7.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 +33 -0
- data/README.md +61 -8
- data/UPGRADING.md +158 -0
- data/app/assets/javascripts/janela/frame_controller.js +38 -3
- data/app/controllers/janela/application_controller.rb +3 -2
- data/app/helpers/janela/frames_helper.rb +18 -5
- data/app/jobs/janela/snapshot_job.rb +47 -6
- data/app/models/janela/snapshot.rb +11 -2
- data/app/views/janela/frames/_frame.html.erb +1 -1
- data/app/views/janela/queries/show.html.erb +6 -1
- data/db/migrate/20260921000001_add_owner_to_janela_snapshots.rb +9 -0
- data/docs/decisions/009-snapshots.md +1 -1
- data/docs/decisions/029-a-panes-frame-is-identified-by-who-it-is.md +112 -0
- data/docs/decisions/030-a-panes-src-belongs-to-turbo.md +93 -0
- data/docs/decisions/031-a-subclass-inherits-the-dashboard.md +110 -0
- data/docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md +156 -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/INDEX.md +12 -6
- data/docs/multi-tenancy.md +87 -18
- data/lib/janela/definition.rb +11 -1
- data/lib/janela/doctor.rb +58 -7
- data/lib/janela/model.rb +46 -10
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +43 -3
- metadata +24 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 91a8da061f836f0c93ed3c6005f704fc8c5830ea2fb8e73f2f77c96f2107c467
|
|
4
|
+
data.tar.gz: ace46d3c7c08ea5423de350f14dcd17e6a303528f62f2a3c60b66b9bf2cc88bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ce044bb0f401c9541a69952212b22492c94d3d68a7a7ce05af8e568b0a77c16adf41b8153291cbc981c80ad2343a16122989890c00ce6feeb5ee3200605ebc82
|
|
7
|
+
data.tar.gz: 1244329924141334f04b3d4f4ac0f15834862d110d0577f5a3739ff2085549a790d76691941df362080a1b470de6927773a3a4cd0cda963f62cb3ca31e0640a4
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ 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.7.0] - 2026-09-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `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).
|
|
13
|
+
- 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).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **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).
|
|
18
|
+
|
|
19
|
+
- **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).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- 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).
|
|
24
|
+
|
|
25
|
+
## [0.6.0] - 2026-09-20
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `janela_pane` takes an optional `id:`, naming the pane's frame instead of fingerprinting it from the query. A host that reconfigures a pane in place, a renderer toggle, a granularity switcher, a "show top 20" link, keeps one stable frame for Turbo to reconcile into rather than a different id every time the query changes. Without `id:`, nothing changes.
|
|
30
|
+
- A `janela--frame:repoint` event, dispatched on a pane or anything inside one with `detail: { url }`, sends that pane to a different query. `janela_frame` listens for it, so nothing has to be wired up and nothing has to reach for the controller. This is how a host changes what a pane shows: a pane's `src` belongs to Turbo, which writes it back whenever a response lands, so Janela keeps its own record of what was asked for and cancels anything else, a host's `src` write included. The event says the query and nothing about filters, because the frame reapplies whatever it is currently filtered to. `data-janela-asked` and `data-janela-src` are how the frame remembers, not an interface, and a host reading or writing them is relying on something that may change (ADR 030, #43).
|
|
31
|
+
- A subclass inherits the dashboard its parent declared and is addressable on its own route key, with nothing to declare: `class WholesaleOrder < Order; end` answers at `/dashboards/wholesale_orders/revenue` and totals its own rows, because the query runs on the subclass and ActiveRecord adds the type condition itself. This is a change of posture, since only a model that declared a `janela` block was addressable before, and it is one of URL surface rather than data surface: an STI subclass is a subset of rows its parent already totals, read through the same scope as everything else. A subclass that wants a different dashboard declares its own block, which replaces its parent's. The cost is noise: a family of a dozen STI types is a dozen entries in `Janela.definitions` and in the form that offers a choice of model, where a host expected one (ADR 031, #11).
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- A subclass came out half declared: it inherited the Ransack allowlist, because Janela defined that as singleton methods on the parent and singleton methods inherit, while `.janela` returned nil and nothing was registered. A host calling `WholesaleOrder.ransack(...)` in its own code was filtering on dimensions no definition behind that class declared. The allowlist Janela generates is now the allowlist of the definition a class reports, whichever class declared it, and a subclass declaring its own block no longer replaces an allowlist the host wrote on a parent (ADR 031, #11).
|
|
36
|
+
- Pointing a pane's turbo frame at a URL differing only in `limit`, `granularity` or `as` left the frame stale with no error. The response was fingerprinted from the query, so it wore an id the frame never had and Turbo had nothing to reconcile it against. A pane rendered into a turbo frame request now answers to the frame that asked, using the id Turbo already sends in its `Turbo-Frame` header, rather than deriving one again from the query. A pane rendered any other way keeps deriving its own id as before. The demo's gallery config controller no longer fetches and swaps a pane's frame by hand: it names each configurable pane and asks the frame to repoint it, which is 22 lines shorter than where it started (ADR 029, #42).
|
|
37
|
+
- Reconfiguring a pane while the frame was filtered refetched that pane unfiltered, so it showed numbers for a filter state nobody was in while every pane beside it stayed filtered, and nothing on the page said so. Reapplying the frame's current filters is part of repointing now rather than something a caller has to remember, which is what the demo's gallery control got wrong: change a pane's granularity there with a value selected and it went back to showing the year (ADR 003, ADR 030, #43).
|
|
38
|
+
|
|
8
39
|
## [0.5.0] - 2026-09-18
|
|
9
40
|
|
|
10
41
|
### Added
|
|
@@ -138,6 +169,8 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
138
169
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
139
170
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
140
171
|
|
|
172
|
+
[0.7.0]: https://github.com/retail-tasker/janela/releases/tag/v0.7.0
|
|
173
|
+
[0.6.0]: https://github.com/retail-tasker/janela/releases/tag/v0.6.0
|
|
141
174
|
[0.5.0]: https://github.com/retail-tasker/janela/releases/tag/v0.5.0
|
|
142
175
|
[0.4.1]: https://github.com/retail-tasker/janela/releases/tag/v0.4.1
|
|
143
176
|
[0.4.0]: https://github.com/retail-tasker/janela/releases/tag/v0.4.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.
|
|
30
|
+
gem "janela", "~> 0.7"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
@@ -129,6 +129,21 @@ end
|
|
|
129
129
|
|
|
130
130
|
A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start. **On SQLite, buckets are UTC**, because SQLite cannot convert time zones: with a non-UTC `Time.zone` a daily bucket is shifted by your offset, and an early-morning row lands in the previous day. Coarser granularities blunt the shift without removing it. If you need local-day buckets on SQLite, store a local date column and use it as a plain dimension.
|
|
131
131
|
|
|
132
|
+
### A subclass inherits
|
|
133
|
+
|
|
134
|
+
Declaring is what a family of classes does once. A subclass of a model with a `janela` block has the same measures and dimensions and its own pane URLs, with nothing to declare:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
class WholesaleOrder < Order
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`/dashboards/wholesale_orders/revenue` totals the wholesale orders and `/dashboards/orders/revenue` totals all of them. Janela knows nothing about single table inheritance: the query runs on the subclass and ActiveRecord adds the type condition itself. A subclass is read through your scope like any other model, so if you authorise per class, the subclass needs an answer of its own.
|
|
142
|
+
|
|
143
|
+
A subclass that wants a different dashboard declares its own `janela` block, which replaces its parent's rather than adding to it. Either way, the Ransack allowlist Janela generates is the allowlist of the definition that class reports, so the two cannot disagree.
|
|
144
|
+
|
|
145
|
+
Every subclass is a definition, so a model with a dozen STI types offers a dozen of them wherever Janela lists what it can draw, such as the form for adding a pane (ADR 031).
|
|
146
|
+
|
|
132
147
|
### How numbers read
|
|
133
148
|
|
|
134
149
|
A measure says what its own number means, and every renderer asks it, so a table cell, a single value and a chart tooltip cannot disagree (ADR 020):
|
|
@@ -194,6 +209,23 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
|
|
|
194
209
|
|
|
195
210
|
A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
|
|
196
211
|
|
|
212
|
+
**Reconfiguring a pane in place**, a renderer toggle, a granularity switcher, a "show top 20" control, takes two things: name the pane with `id:`, then ask the frame to repoint it.
|
|
213
|
+
|
|
214
|
+
```erb
|
|
215
|
+
<%= janela_pane Order, :revenue, by: :status, as: :bar, id: "revenue-by-status" %>
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
const pane = document.getElementById("revenue-by-status")
|
|
220
|
+
pane.dispatchEvent(new CustomEvent("janela--frame:repoint", {
|
|
221
|
+
bubbles: true, detail: { url: "/dashboards/orders/revenue/status?limit=20" }
|
|
222
|
+
}))
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`id:` gives the frame a name you chose rather than a fingerprint of its own query, which would move every time that query changed and leave Turbo nothing to reconcile into. That is necessary and it is not enough on its own: a pane's `src` belongs to Turbo, which writes it back whenever a response lands, so writing `src` yourself has the request cancelled and the pane put back where it was, with no error and the old numbers still on screen. The event is how you say what you want instead. `janela_frame` listens for it, so anything inside a frame can dispatch it, from a Stimulus controller (`this.dispatch("repoint", { prefix: "janela--frame", target: pane, detail: { url } })`) or from plain JavaScript as above.
|
|
226
|
+
|
|
227
|
+
Say the query and nothing about filters: the frame reapplies whatever it is currently filtered to, so a repointed pane still agrees with the panes beside it, and any `q[...]` on the URL you pass is dropped in favour of them (ADR 029, ADR 030).
|
|
228
|
+
|
|
197
229
|
### Frames
|
|
198
230
|
|
|
199
231
|
A dashboard does not have to be written in ERB. A frame is a record, so the person who decides which panes a dashboard has and how wide each one is does not need a deploy to change it (ADR 012):
|
|
@@ -296,6 +328,8 @@ Every pane has its own URL under the mount, and a Turbo Frame in a dashboard loa
|
|
|
296
328
|
|
|
297
329
|
The model is its route key (`orders`, `sales_orders`), then the measure, then optionally the dimension. Where an analyst would say *by*, the URL has a `/`; *where* is a `q` filter; *as a bar chart* is `?as=bar`; *top ten* is `?limit=10`; *as of* a snapshot is `/snapshots/:id/` in front. Category panes are always ordered by the measure, largest first; time panes are chronological. A pane opened on its own renders with its filters applied, so a filtered pane is a link you can send someone. ADR 005 has the grammar, ADR 011 the layout it renders in.
|
|
298
330
|
|
|
331
|
+
**Sending an existing pane to one of these URLs takes both halves of Reconfiguring a pane in place, above: name it with `id:`, and repoint it through the frame.** Without the `id:` the response wears an id the frame never had and Turbo has nothing to reconcile, so the pane keeps its old numbers with no error at all. Without the event, `src` is not yours to write and the request is cancelled, with the same silence (ADR 029, ADR 030).
|
|
332
|
+
|
|
299
333
|
### What Janela can draw
|
|
300
334
|
|
|
301
335
|
`Janela.renderers`, `Janela.granularities` and `Janela.offered_limits` answer what a pane can be drawn as, without reaching into `Janela::Query::RENDERERS`, `Janela::Dimension::GRANULARITIES` or `Janela::Pane::OFFERED_LIMITS`. `Janela.definitions` answers the other half: every model that declares a `janela` block, with its own measures and dimensions. A gallery of every renderer, live against your own data, is a page you build from those four calls and `janela_pane`, not one the engine serves (ADR 026, ADR 027):
|
|
@@ -314,13 +348,15 @@ The model is its route key (`orders`, `sales_orders`), then the measure, then op
|
|
|
314
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.
|
|
315
349
|
|
|
316
350
|
```ruby
|
|
317
|
-
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|
|
|
318
352
|
take.pane Order, :revenue, on: policy_scope(Order)
|
|
319
353
|
take.pane Order, :revenue, by: :status, on: policy_scope(Order)
|
|
320
354
|
take.pane Order, :revenue, by: :placed_on, granularity: :week
|
|
321
355
|
end
|
|
322
356
|
```
|
|
323
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
|
+
|
|
324
360
|
Render a stored pane the same way you render a live one:
|
|
325
361
|
|
|
326
362
|
```erb
|
|
@@ -356,7 +392,22 @@ Do not point Janela at your **application** layout. Janela is an isolated engine
|
|
|
356
392
|
|
|
357
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.
|
|
358
394
|
|
|
359
|
-
`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).
|
|
360
411
|
|
|
361
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.
|
|
362
413
|
|
|
@@ -377,7 +428,7 @@ It is *prepended* so it runs before any filter on your `ApplicationController` t
|
|
|
377
428
|
|
|
378
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.`.
|
|
379
430
|
|
|
380
|
-
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. `test/dummy/app/controllers/application_controller.rb` is the smallest honest example of the wiring.
|
|
381
432
|
|
|
382
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.
|
|
383
434
|
|
|
@@ -410,9 +461,11 @@ bin/rails janela:doctor
|
|
|
410
461
|
Reads your application and lists what still needs doing: identifiers left over
|
|
411
462
|
from an earlier version, Stimulus controllers you have not registered, tables
|
|
412
463
|
you have not migrated, a `through:` dimension whose associated model does not
|
|
413
|
-
allowlist the attribute, a
|
|
414
|
-
|
|
415
|
-
|
|
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.
|
|
416
469
|
|
|
417
470
|
Every finding names the check that produced it:
|
|
418
471
|
|
|
@@ -450,7 +503,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
450
503
|
|
|
451
504
|
## Status
|
|
452
505
|
|
|
453
|
-
**v0.
|
|
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).
|
|
454
507
|
|
|
455
508
|
## Development
|
|
456
509
|
|
data/UPGRADING.md
CHANGED
|
@@ -12,6 +12,164 @@ bin/rails janela:doctor
|
|
|
12
12
|
|
|
13
13
|
It reads your application and lists what still needs changing.
|
|
14
14
|
|
|
15
|
+
## 0.6.0 to 0.7.0
|
|
16
|
+
|
|
17
|
+
Janela has stopped guessing what may be read, in the two places it used
|
|
18
|
+
to default to every row: a pane in a request (ADR 032) and a scheduled
|
|
19
|
+
snapshot (ADR 034). Three steps, and most applications have already
|
|
20
|
+
taken the first.
|
|
21
|
+
|
|
22
|
+
**1. Say what may be read, if you have not.**
|
|
23
|
+
|
|
24
|
+
If your `ApplicationController` defines `policy_scope`, nothing changes.
|
|
25
|
+
If you use Pundit, nothing changes. If neither is true, every dashboard,
|
|
26
|
+
pane and inline frame now raises `Janela::Unscoped` where it previously
|
|
27
|
+
totalled every row:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class ApplicationController < ActionController::Base
|
|
31
|
+
+ private
|
|
32
|
+
+ def policy_scope(model) = model.all
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That line is an assertion, not a formality: it says every visitor who can
|
|
37
|
+
reach a dashboard may read every row of every model on it. "One tenant"
|
|
38
|
+
and "nothing here is worth hiding from staff" are different claims, and
|
|
39
|
+
only the second one licenses `model.all`. If it is not true of your
|
|
40
|
+
application, return something narrower.
|
|
41
|
+
`docs/multi-tenancy.md` has the wiring for acts_as_tenant, CanCanCan and
|
|
42
|
+
the rest.
|
|
43
|
+
|
|
44
|
+
Find it before a visitor does:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bin/rails janela:doctor
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`unscoped-reads` reports the absence as an error and prints the line to
|
|
51
|
+
add.
|
|
52
|
+
|
|
53
|
+
**2. Take the snapshot owner migration, if you use snapshots.**
|
|
54
|
+
|
|
55
|
+
`janela_snapshots` gains a nullable polymorphic owner so your policy can
|
|
56
|
+
filter a snapshot the way it already filters a frame:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bin/rails janela:install:migrations
|
|
60
|
+
bin/rails db:migrate
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Existing snapshots keep a nil owner and keep working. Pass `owner:` when
|
|
64
|
+
you take new ones, and add `Janela::Snapshot` to whatever your policy
|
|
65
|
+
already does for `Janela::Frame`:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
- Janela::Snapshot.take(name: "September") { |take| ... }
|
|
69
|
+
+ Janela::Snapshot.take(name: "September", owner: Current.account) { |take| ... }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The owner says who a snapshot belongs to. What the numbers inside it
|
|
73
|
+
cover is the next step.
|
|
74
|
+
|
|
75
|
+
**3. Tell `Janela::SnapshotJob` what rows to freeze, if you schedule
|
|
76
|
+
snapshots.**
|
|
77
|
+
|
|
78
|
+
The job used to take every pane over the model's default scope, which is
|
|
79
|
+
your tenant's rows if your tenancy is enforced on your models and every
|
|
80
|
+
row if it lives in your policies. Janela cannot tell which application it
|
|
81
|
+
is in, so it has stopped choosing (ADR 034). It now raises
|
|
82
|
+
`Janela::Unscoped` unless you answer.
|
|
83
|
+
|
|
84
|
+
If your tenancy is on your models, or you have one tenant, say so:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
- Janela::SnapshotJob.perform_later(name: "September", panes: [...])
|
|
88
|
+
+ Janela::SnapshotJob.perform_later(name: "September", scope: :model_default, panes: [...])
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
If your scoping lives in your policies, `model.all` is every row and no
|
|
92
|
+
symbol can carry the relation you want. Answer in Ruby instead, and
|
|
93
|
+
schedule your own job:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class TenantSnapshotJob < Janela::SnapshotJob
|
|
97
|
+
private def scope_for(model) = model.where(account: owner)
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Override `scope_for` and the `scope:` argument is not consulted, because
|
|
102
|
+
the method that reads it is the one you replaced. `name`, `owner` and
|
|
103
|
+
`filters` are readable beside it, so there is no need to override
|
|
104
|
+
`perform`. This replaces the old advice to write a job around
|
|
105
|
+
`Snapshot.take` from scratch, which still works and is now one method
|
|
106
|
+
longer than it needs to be.
|
|
107
|
+
|
|
108
|
+
**Drain the queue, or expect the retries.** A `SnapshotJob` enqueued
|
|
109
|
+
before you deploy was serialised without `scope:` and will raise when it
|
|
110
|
+
performs. Nothing in the diff shows you this. Let the queue empty before
|
|
111
|
+
deploying, or re-enqueue what fails afterwards.
|
|
112
|
+
|
|
113
|
+
**What did not change.** `Order.janela.query(:revenue)` called from your
|
|
114
|
+
own Ruby still runs over `Order.all`, because you wrote that call and the
|
|
115
|
+
scope was yours to choose. Only what Janela decides on your behalf inside
|
|
116
|
+
a request has stopped guessing. Frames, pane rows and snapshots are read
|
|
117
|
+
through the same scope they always were.
|
|
118
|
+
|
|
119
|
+
## 0.5.0 to 0.6.0
|
|
120
|
+
|
|
121
|
+
How single table inheritance is handled changed (ADR 031). Nothing here
|
|
122
|
+
applies unless your application has STI subclasses under a model that
|
|
123
|
+
declares a `janela` block. If it does not, upgrade and read no further.
|
|
124
|
+
|
|
125
|
+
**1. A named subclass is now registered and addressable.**
|
|
126
|
+
|
|
127
|
+
Before, only a class with its own `janela` block answered at a URL. Now
|
|
128
|
+
every named subclass of one does, on its own route key:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
/insights/orders/revenue as before
|
|
132
|
+
+ /insights/wholesale_orders/revenue new in 0.6.0
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The numbers are the subclass's own rows, because the query runs on the
|
|
136
|
+
subclass and ActiveRecord adds the type condition itself. There is
|
|
137
|
+
nothing to declare and nothing to register.
|
|
138
|
+
|
|
139
|
+
Scoping is unchanged: a subclass reads through `janela_scope` like every
|
|
140
|
+
other model, so an application authorising with `policy_scope` already
|
|
141
|
+
covers the new addresses. One that instead gates on the request path now
|
|
142
|
+
has paths it has not listed, and should list them.
|
|
143
|
+
|
|
144
|
+
**2. `Janela.definitions` returns one entry per subclass.**
|
|
145
|
+
|
|
146
|
+
A family of a dozen STI types is a dozen entries, where a form offering a
|
|
147
|
+
choice of model previously showed one. If you want only the classes that
|
|
148
|
+
declared a dashboard:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
- Janela.definitions
|
|
152
|
+
+ Janela.definitions.select { |definition| definition.model.base_class == definition.model }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**3. A subclass's Ransack allowlist now matches the dashboard it reports.**
|
|
156
|
+
|
|
157
|
+
This was wrong before rather than merely different. A subclass inherited
|
|
158
|
+
the allowlist Janela generated on its parent, because those are singleton
|
|
159
|
+
methods and singleton methods inherit, while `.janela` returned nil and
|
|
160
|
+
nothing was registered. So `WholesaleOrder.ransack(...)` in your own code
|
|
161
|
+
filtered on dimensions no definition behind that class had declared. The
|
|
162
|
+
allowlist is now the allowlist of the definition a class reports,
|
|
163
|
+
whichever class declared it.
|
|
164
|
+
|
|
165
|
+
Janela also no longer replaces an allowlist you wrote yourself on a
|
|
166
|
+
parent class when a subclass declares its own block.
|
|
167
|
+
|
|
168
|
+
**What did not change.** A model that declares its own `janela` block, an
|
|
169
|
+
application with no STI, every `janela_frame` and `janela_pane` call you
|
|
170
|
+
have already written, and every filter already in a URL. An anonymous
|
|
171
|
+
subclass is still not registered, having no route key to be addressed by.
|
|
172
|
+
|
|
15
173
|
## 0.4.1 to 0.5.0
|
|
16
174
|
|
|
17
175
|
A filter is now bound to what kind of dimension it names (ADR 025). Most
|
|
@@ -102,6 +102,34 @@ export default class extends Controller {
|
|
|
102
102
|
if (Object.keys(this.filtersValue).length) this.filtersValue = {}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// A host changes what a pane shows by asking here, and never by writing
|
|
106
|
+
// src or one of the dataset records below. Turbo writes src back onto a
|
|
107
|
+
// frame when a response lands, so src does not say what was asked for and
|
|
108
|
+
// this controller keeps its own record instead (#33); a host writing that
|
|
109
|
+
// record by hand has to write two attributes in the right order and
|
|
110
|
+
// reapply the frame's filters itself, and only this controller knows what
|
|
111
|
+
// those filters are. Getting it wrong is silent: the pane shows numbers
|
|
112
|
+
// for a filter state nobody is in, beside panes that are still filtered
|
|
113
|
+
// (ADR 003, ADR 030, #43).
|
|
114
|
+
//
|
|
115
|
+
// Dispatched on the pane, or on anything inside it, with the query to go
|
|
116
|
+
// to. Filters are the frame's, so a caller says nothing about them:
|
|
117
|
+
//
|
|
118
|
+
// pane.dispatchEvent(new CustomEvent("janela--frame:repoint",
|
|
119
|
+
// { bubbles: true, detail: { url: "/dashboards/orders/revenue/status?limit=5" } }))
|
|
120
|
+
repoint(event) {
|
|
121
|
+
const pane = this.paneTargets.find((each) => each.contains(event.target))
|
|
122
|
+
const query = new URL(event.detail.url, window.location.origin)
|
|
123
|
+
this.stripFilters(query)
|
|
124
|
+
|
|
125
|
+
// The query without filters first, since it is what this pane's URL is
|
|
126
|
+
// rebuilt from on the next click as well as on the line below.
|
|
127
|
+
pane.dataset.janelaSrc = query.pathname + query.search
|
|
128
|
+
const url = this.urlFor(pane)
|
|
129
|
+
pane.dataset.janelaAsked = url.href
|
|
130
|
+
pane.src = url.href
|
|
131
|
+
}
|
|
132
|
+
|
|
105
133
|
// Whatever is selected for one key, as a set of strings. A filter arrives as
|
|
106
134
|
// an array from a click and as a string from a hand written _eq link.
|
|
107
135
|
valuesFor(filters, key) {
|
|
@@ -160,9 +188,7 @@ export default class extends Controller {
|
|
|
160
188
|
// pushed: a click is not a place the back button should return to.
|
|
161
189
|
syncPageUrl() {
|
|
162
190
|
const url = new URL(window.location.href)
|
|
163
|
-
|
|
164
|
-
if (key.startsWith("q[")) url.searchParams.delete(key)
|
|
165
|
-
}
|
|
191
|
+
this.stripFilters(url)
|
|
166
192
|
this.writeFilters(url)
|
|
167
193
|
if (url.href !== window.location.href) history.replaceState(history.state, "", url)
|
|
168
194
|
}
|
|
@@ -173,6 +199,15 @@ export default class extends Controller {
|
|
|
173
199
|
return url
|
|
174
200
|
}
|
|
175
201
|
|
|
202
|
+
// The filters on a URL are Janela's to write, so whatever is already there
|
|
203
|
+
// comes off before the current selection goes on: a page URL carrying the
|
|
204
|
+
// filters a page was opened with, or a query a host handed to repoint.
|
|
205
|
+
stripFilters(url) {
|
|
206
|
+
for (const key of [ ...url.searchParams.keys() ]) {
|
|
207
|
+
if (key.startsWith("q[")) url.searchParams.delete(key)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
176
211
|
// Sorted, keys and values both, so the browser serialises a selection the
|
|
177
212
|
// same way every time and an unchanged src is never reloaded.
|
|
178
213
|
writeFilters(url) {
|
|
@@ -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
|
|
@@ -9,16 +9,20 @@ module Janela
|
|
|
9
9
|
def janela_frame(frame = nil, charts: true, &block)
|
|
10
10
|
return render("janela/frames/frame", frame: frame, filters: janela_page_filters, charts: charts) if frame
|
|
11
11
|
|
|
12
|
-
tag.div(data: { controller: "janela--frame", action:
|
|
12
|
+
tag.div(data: { controller: "janela--frame", action: janela_frame_actions,
|
|
13
13
|
janela__frame_filters_value: janela_page_filters.to_json }, &block)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# id: names the pane's frame instead of fingerprinting it from the query,
|
|
17
|
+
# so a host that changes the query in place (a renderer, granularity or
|
|
18
|
+
# limit control) keeps one stable frame for Turbo to reconcile into
|
|
19
|
+
# rather than a different id every time the query changes (ADR 029).
|
|
20
|
+
def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, id: nil)
|
|
17
21
|
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
|
|
18
22
|
base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
|
|
19
23
|
src = janela_page_filters.empty? ? base : janela_routes.pane_path(model.model_name.route_key, measure, by, **query, q: janela_page_filters)
|
|
20
24
|
|
|
21
|
-
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
|
|
25
|
+
turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
|
|
22
26
|
src: src,
|
|
23
27
|
loading: :lazy,
|
|
24
28
|
data: { janela__frame_target: "pane", janela_src: base }
|
|
@@ -35,14 +39,23 @@ module Janela
|
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
private
|
|
42
|
+
# Wired once per frame, so a host asks a pane to go to a different query
|
|
43
|
+
# by dispatching an event from anywhere inside rather than by reaching
|
|
44
|
+
# for the controller itself (ADR 030).
|
|
45
|
+
def janela_frame_actions
|
|
46
|
+
"keydown.esc->janela--frame#clear janela--frame:repoint->janela--frame#repoint"
|
|
47
|
+
end
|
|
48
|
+
|
|
38
49
|
def janela_frame_classes(frame)
|
|
39
50
|
[ "janela-frame", "janela-cols-#{frame.columns}", "janela-gap-#{frame.gap}" ]
|
|
40
51
|
end
|
|
41
52
|
|
|
42
53
|
# A frame rendered inline runs its queries in the host's own request, so
|
|
43
|
-
# 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).
|
|
44
57
|
def janela_scope(model)
|
|
45
|
-
|
|
58
|
+
Janela.scope(controller, model)
|
|
46
59
|
end
|
|
47
60
|
|
|
48
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)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<%= tag.div class: janela_frame_classes(frame),
|
|
2
|
-
data: { controller: "janela--frame", action:
|
|
2
|
+
data: { controller: "janela--frame", action: janela_frame_actions,
|
|
3
3
|
janela__frame_filters_value: filters.to_json } do %>
|
|
4
4
|
<%= render partial: "janela/frames/pane", collection: frame.panes, as: :pane, locals: { filters: filters, charts: charts } %>
|
|
5
5
|
<% end %>
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
<%# A pane rendered into a turbo frame request answers to the frame that
|
|
2
|
+
asked, using the id Turbo already sends in its Turbo-Frame header,
|
|
3
|
+
rather than fingerprinting the id again from a query that may have just
|
|
4
|
+
changed underneath it (ADR 029). Rendered any other way, it still
|
|
5
|
+
derives its own id. %>
|
|
1
6
|
<% content_for :title, @query.title %>
|
|
2
|
-
<%= turbo_frame_tag @query.turbo_frame_id do %>
|
|
7
|
+
<%= turbo_frame_tag turbo_frame_request_id.presence || @query.turbo_frame_id do %>
|
|
3
8
|
<%= render "janela/queries/query", query: @query, result: @result %>
|
|
4
9
|
<% end %>
|
|
@@ -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
|