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