janela 0.8.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 +14 -0
- data/README.md +33 -2
- data/UPGRADING.md +48 -0
- data/app/assets/javascripts/janela/frame_controller.js +15 -0
- data/app/assets/stylesheets/janela.css +16 -8
- 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/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 +18 -12
- data/docs/multi-tenancy.md +22 -0
- data/docs/naming.md +7 -0
- data/docs/roadmap.md +129 -8
- data/docs/theming.md +17 -15
- data/lib/janela/definition.rb +8 -0
- data/lib/janela/version.rb +1 -1
- metadata +25 -17
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,19 @@ 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
|
+
|
|
8
21
|
## [0.8.0] - 2026-09-23
|
|
9
22
|
|
|
10
23
|
### Added
|
|
@@ -192,6 +205,7 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
192
205
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
193
206
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
194
207
|
|
|
208
|
+
[0.9.0]: https://github.com/retail-tasker/janela/releases/tag/v0.9.0
|
|
195
209
|
[0.8.0]: https://github.com/retail-tasker/janela/releases/tag/v0.8.0
|
|
196
210
|
[0.7.0]: https://github.com/retail-tasker/janela/releases/tag/v0.7.0
|
|
197
211
|
[0.6.0]: https://github.com/retail-tasker/janela/releases/tag/v0.6.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:
|
|
@@ -503,7 +534,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
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,54 @@ 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
|
+
|
|
15
63
|
## 0.7.0 to 0.8.0
|
|
16
64
|
|
|
17
65
|
Two steps, each only if it applies to you: one if you name a parent
|
|
@@ -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,18 +91,20 @@ 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
|
|
|
90
97
|
/* A host whose own markup already says what a pane is puts this on any
|
|
91
|
-
ancestor, and the caption
|
|
92
|
-
without leaving the accessibility tree. Hidden rather than
|
|
93
|
-
purpose:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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). */
|
|
98
105
|
.janela-own-headings table.janela-pane caption,
|
|
99
|
-
.janela-own-headings .janela-value-label
|
|
106
|
+
.janela-own-headings .janela-value-label,
|
|
107
|
+
.janela-own-headings .janela-chart-title {
|
|
100
108
|
position: absolute;
|
|
101
109
|
width: 1px;
|
|
102
110
|
height: 1px;
|
|
@@ -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
|
data/app/models/janela/query.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Janela
|
|
|
7
7
|
class Query
|
|
8
8
|
RENDERERS = %w[table bar line].freeze
|
|
9
9
|
|
|
10
|
-
attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :snapshot
|
|
10
|
+
attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :fixed, :snapshot
|
|
11
11
|
|
|
12
12
|
# The helper renders the turbo frame and the controller renders its
|
|
13
13
|
# replacement, so both derive the id the same way from the same parameters.
|
|
@@ -17,13 +17,14 @@ module Janela
|
|
|
17
17
|
parts.compact.join("_")
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, snapshot: nil, title: nil)
|
|
20
|
+
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, fixed: {}, snapshot: nil, title: nil)
|
|
21
21
|
@definition = definition
|
|
22
22
|
@title = title
|
|
23
23
|
@measure = measure
|
|
24
24
|
@dimension = dimension
|
|
25
25
|
@renderer = renderer.to_s
|
|
26
26
|
@filters = filters
|
|
27
|
+
@fixed = fixed
|
|
27
28
|
@snapshot = snapshot
|
|
28
29
|
|
|
29
30
|
raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
|
|
@@ -97,6 +98,10 @@ module Janela
|
|
|
97
98
|
def result(on: nil)
|
|
98
99
|
return snapshot.stored_result(self) if frozen?
|
|
99
100
|
|
|
101
|
+
# The fixed filter applies even on this pane's own dimension, unlike the
|
|
102
|
+
# reader's: it is the host saying which rows the frame is about, not a
|
|
103
|
+
# selection this pane should show the alternatives to (ADR 040).
|
|
104
|
+
on = definition.narrow(on || model.all, fixed) if fixed.present?
|
|
100
105
|
definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
|
|
101
106
|
end
|
|
102
107
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%# Words an analyst wrote, escaped like any other string, or a partial the
|
|
2
|
+
host wrote that is handed the same words (ADR 039). Nothing a row holds
|
|
3
|
+
reaches the page as markup. The same shape as a query pane, a grid cell
|
|
4
|
+
holding the pane, so a theme that draws the cell as glass draws this too. %>
|
|
5
|
+
<%= tag.div class: "janela-span-#{pane.span}", id: "janela_pane_#{pane.id}" do %>
|
|
6
|
+
<%= tag.div class: [ "janela-pane", "janela-content" ] do %>
|
|
7
|
+
<% if pane.partial? %>
|
|
8
|
+
<%# The analyst's words, and what the partial is about: its row, its frame,
|
|
9
|
+
so the frame's owner and key, and the filter the host fixed for this
|
|
10
|
+
render (#60). Not the reader's q[...]: this pane is not refreshed when
|
|
11
|
+
they click, so a figure scoped by it would be stale after the first. %>
|
|
12
|
+
<%= render pane.partial_path, heading: pane.heading, body: pane.body, link: pane.link,
|
|
13
|
+
pane: pane, frame: frame, where: fixed %>
|
|
14
|
+
<% else %>
|
|
15
|
+
<% if pane.heading.present? %>
|
|
16
|
+
<h2 class="janela-content-heading"><%= pane.link.present? ? link_to(pane.heading, pane.link) : pane.heading %></h2>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% pane.body.to_s.split(/\n\s*\n/).map(&:strip).reject(&:empty?).each do |paragraph| %>
|
|
19
|
+
<p><%= paragraph %></p>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% if pane.link.present? && pane.heading.blank? %>
|
|
22
|
+
<p><%= link_to pane.link, pane.link %></p>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% end %>
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
<%= tag.div class: janela_frame_classes(frame),
|
|
2
2
|
data: { controller: "janela--frame", action: janela_frame_actions,
|
|
3
3
|
janela__frame_filters_value: filters.to_json } do %>
|
|
4
|
-
|
|
4
|
+
<% frame.panes.each do |pane| %>
|
|
5
|
+
<%# A content pane has no query, so it is not a turbo frame and the frame
|
|
6
|
+
controller has nothing of it to refresh (ADR 039). %>
|
|
7
|
+
<% if pane.query? %>
|
|
8
|
+
<%= render "janela/frames/pane", pane: pane, filters: filters, fixed: fixed, charts: charts %>
|
|
9
|
+
<% else %>
|
|
10
|
+
<%= render "janela/frames/content", pane: pane, frame: frame, fixed: fixed %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
5
13
|
<% end %>
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
change, which is what makes cross-filtering work from here. %>
|
|
6
6
|
<%# A table needs no JavaScript, so it is what a surface with no chart runtime
|
|
7
7
|
shows in place of an empty canvas (ADR 018). %>
|
|
8
|
-
<% query = pane.query(filters: filters, renderer: pane.chart? && !charts ? "table" : pane.renderer) %>
|
|
8
|
+
<% query = pane.query(filters: filters, fixed: fixed, renderer: pane.chart? && !charts ? "table" : pane.renderer) %>
|
|
9
9
|
<%= turbo_frame_tag pane.turbo_frame_id, class: "janela-span-#{pane.span}",
|
|
10
|
-
data: { janela__frame_target: "pane", janela_src: janela_routes.frame_pane_path(pane.frame_id, pane) } do %>
|
|
10
|
+
data: { janela__frame_target: "pane", janela_src: janela_routes.frame_pane_path(pane.frame_id, pane, where: fixed.presence) } do %>
|
|
11
11
|
<%= render "janela/queries/query", query: query, result: query.result(on: janela_scope(query.model)) %>
|
|
12
12
|
<% end %>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<%# Words for a content pane (ADR 039). Plain fields, because what is typed
|
|
2
|
+
here is shown as text: there is no markup to write. %>
|
|
3
|
+
<%= form_with model: pane, url: pane.persisted? ? frame_pane_path(frame, pane) : frame_panes_path(frame), class: "janela-form" do |form| %>
|
|
4
|
+
<%= render "janela/shared/errors", record: pane %>
|
|
5
|
+
<%= form.hidden_field :kind %>
|
|
6
|
+
<%= form.hidden_field :partial if pane.partial? %>
|
|
7
|
+
|
|
8
|
+
<div class="janela-field">
|
|
9
|
+
<%= form.label :heading %>
|
|
10
|
+
<%= form.text_field :heading %>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div class="janela-field">
|
|
14
|
+
<%= form.label :body %>
|
|
15
|
+
<%= form.text_area :body, rows: 5 %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="janela-field">
|
|
19
|
+
<%= form.label :link %>
|
|
20
|
+
<%= form.text_field :link %>
|
|
21
|
+
<span class="janela-hint"><%= t("janela.panes.link_hint") %></span>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="janela-field">
|
|
25
|
+
<%= form.label :span %>
|
|
26
|
+
<%= form.select :span, Janela::Pane::SPANS.to_a %>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div class="janela-actions">
|
|
30
|
+
<%= form.submit t("janela.actions.save"), class: "janela-button" %>
|
|
31
|
+
<%= link_to t("janela.actions.cancel"), edit_frame_path(frame) %>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|