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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +33 -2
  4. data/UPGRADING.md +48 -0
  5. data/app/assets/javascripts/janela/frame_controller.js +15 -0
  6. data/app/assets/stylesheets/janela.css +16 -8
  7. data/app/controllers/janela/application_controller.rb +7 -0
  8. data/app/controllers/janela/panes_controller.rb +22 -7
  9. data/app/controllers/janela/queries_controller.rb +2 -1
  10. data/app/helpers/janela/frames_helper.rb +26 -7
  11. data/app/models/janela/frame.rb +20 -0
  12. data/app/models/janela/pane.rb +67 -4
  13. data/app/models/janela/query.rb +7 -2
  14. data/app/views/janela/frames/_content.html.erb +26 -0
  15. data/app/views/janela/frames/_frame.html.erb +9 -1
  16. data/app/views/janela/frames/_pane.html.erb +2 -2
  17. data/app/views/janela/panes/_content_form.html.erb +33 -0
  18. data/app/views/janela/panes/_row.html.erb +1 -1
  19. data/app/views/janela/panes/edit.html.erb +5 -1
  20. data/app/views/janela/panes/new.html.erb +6 -1
  21. data/app/views/janela/queries/_query.html.erb +15 -11
  22. data/config/locales/en.yml +5 -0
  23. data/db/migrate/20260924000001_add_content_to_janela_panes.rb +16 -0
  24. data/db/migrate/20260924000002_add_key_to_janela_frames.rb +10 -0
  25. data/docs/composing.md +269 -0
  26. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +194 -0
  27. data/docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md +168 -0
  28. data/docs/decisions/040-a-host-can-fix-a-frames-filter.md +145 -0
  29. data/docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md +112 -0
  30. data/docs/decisions/042-a-charts-title-is-a-figcaption.md +174 -0
  31. data/docs/decisions/INDEX.md +18 -12
  32. data/docs/multi-tenancy.md +22 -0
  33. data/docs/naming.md +7 -0
  34. data/docs/roadmap.md +129 -8
  35. data/docs/theming.md +17 -15
  36. data/lib/janela/definition.rb +8 -0
  37. data/lib/janela/version.rb +1 -1
  38. metadata +25 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a28af383164e7bc1176c8b41bcff4d4ee384bbdaac7313b66a781c5c047a4fb8
4
- data.tar.gz: 7611b5e28e04d9dc3cf45a940eba1aae33e2b80beb708f3a24b7ecc12a650819
3
+ metadata.gz: e1dbbea1c971d06f91daeb2e1bbd3ecc0a9d056363efd0e61664bf9cac51da9b
4
+ data.tar.gz: 50e76f042113f5e5e071f20d242c62176672aba367ab676448f6e722f1df9f2d
5
5
  SHA512:
6
- metadata.gz: 8b32263ebc50a9d33ca95a9629bde4a58937370ea70df0fe17c58d651a0f87953aeb668dc363933c0aaa5e36a2b4bd663ffbdf4ea670cf5eec3dd5e3f7ee380d
7
- data.tar.gz: 7651430b673d289b5bfa1a650c66351d580714c542cba06ec046cac4236dde21079c5debeddc9d27439442596b8a317aa60adccda1d1c3c9f1216b60a0d73f54
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.8"
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.8.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar and line charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames, STI subclasses, the engine's own pages for reading and editing them and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, drill-down on time panes, other chart types. [Where Janela Is Going](docs/roadmap.md) says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
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 and the single value's label stop being drawn
92
- without leaving the accessibility tree. Hidden rather than removed on
93
- purpose: a caption is the table's accessible name, so display: none would
94
- land a screen reader on a grid of numbers with nothing to say what they
95
- measure. The rule ships here because that is easy to get wrong and every
96
- host would otherwise write it (ADR 036, #26). A chart pane needs nothing:
97
- its title was only ever an aria-label. */
98
+ 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 = @pane.query(filters: filters)
10
+ raise NotFound, "pane #{@pane.id} holds content, not a query" unless @pane.query?
11
+
12
+ @query = @pane.query(filters: filters, fixed: fixed_filters)
10
13
  @result = @query.result(on: janela_scope(@query.model))
11
14
  end
12
15
 
13
16
  # Two steps, because the engine's pages have no JavaScript to refresh one
14
17
  # select from another: the first picks a model, the second offers exactly
15
18
  # that model's measures and dimensions (ADR 018).
19
+ # Step one also offers words, and each partial the host wrote for them
20
+ # (ADR 039), in the same list as the models, since each is a choice of
21
+ # what the pane holds.
16
22
  def new
17
- @pane = @frame.panes.build(model: params[:model], renderer: "table", span: 1)
18
- @definition = @pane.model.present? ? Janela.definition!(@pane.model) : nil
23
+ @pane = build_pane(params[:model])
24
+ @definition = @pane.query? && @pane.model.present? ? Janela.definition!(@pane.model) : nil
19
25
  end
20
26
 
21
27
  def edit
22
- @definition = @pane.definition
28
+ @definition = @pane.definition if @pane.query?
23
29
  end
24
30
 
25
31
  def create
@@ -28,7 +34,7 @@ module Janela
28
34
  if @pane.save
29
35
  redirect_to edit_frame_path(@frame), notice: t("janela.panes.created")
30
36
  else
31
- @definition = @pane.model.present? ? Janela.definition!(@pane.model) : nil
37
+ @definition = @pane.query? && @pane.model.present? ? Janela.definition!(@pane.model) : nil
32
38
  render :new, status: :unprocessable_entity
33
39
  end
34
40
  end
@@ -37,7 +43,7 @@ module Janela
37
43
  if @pane.update(pane_params)
38
44
  redirect_to edit_frame_path(@frame), notice: t("janela.panes.updated")
39
45
  else
40
- @definition = @pane.definition
46
+ @definition = @pane.definition if @pane.query?
41
47
  render :edit, status: :unprocessable_entity
42
48
  end
43
49
  end
@@ -70,7 +76,16 @@ module Janela
70
76
  end
71
77
 
72
78
  def pane_params
73
- params.expect(pane: [ :model, :measure, :dimension, :renderer, :granularity, :limit, :span, :title ])
79
+ params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :span, :title,
80
+ :heading, :body, :link, :partial ])
81
+ end
82
+
83
+ def build_pane(choice)
84
+ case choice.to_s
85
+ when "text" then @frame.panes.build(kind: "text", span: 1)
86
+ when /\Apartial:(.+)\z/ then @frame.panes.build(kind: "partial", partial: $1, span: 1)
87
+ else @frame.panes.build(model: choice, renderer: "table", span: 1)
88
+ end
74
89
  end
75
90
  end
76
91
  end
@@ -8,7 +8,8 @@ module Janela
8
8
  renderer: params.fetch(:as, "table"),
9
9
  granularity: params[:granularity],
10
10
  limit: params[:limit],
11
- filters: filters
11
+ filters: filters,
12
+ fixed: fixed_filters
12
13
  )
13
14
 
14
15
  @result = @query.result(on: janela_scope(@query.model))
@@ -6,11 +6,20 @@ module Janela
6
6
  # renders filtered before any JavaScript runs.
7
7
  # charts: false renders a chart pane as its table, for a surface with no
8
8
  # chart runtime. The engine's own pages are the case (ADR 018).
9
- def janela_frame(frame = nil, charts: true, &block)
10
- return render("janela/frames/frame", frame: frame, filters: janela_page_filters, charts: charts) if frame
9
+ # where: is a filter the host fixes for this render, such as the record
10
+ # whose page this is. It goes into every pane's base URL rather than the
11
+ # frame's filters, so nothing the reader clicks can take it off (ADR 040).
12
+ def janela_frame(frame = nil, where: {}, charts: true, &block)
13
+ fixed = where.to_h.stringify_keys.sort.to_h
14
+ return render("janela/frames/frame", frame: frame, filters: janela_page_filters, fixed: fixed, charts: charts) if frame
11
15
 
12
- tag.div(data: { controller: "janela--frame", action: janela_frame_actions,
13
- janela__frame_filters_value: janela_page_filters.to_json }, &block)
16
+ begin
17
+ outer, @janela_fixed_filters = @janela_fixed_filters, fixed
18
+ tag.div(data: { controller: "janela--frame", action: janela_frame_actions,
19
+ janela__frame_filters_value: janela_page_filters.to_json }, &block)
20
+ ensure
21
+ @janela_fixed_filters = outer
22
+ end
14
23
  end
15
24
 
16
25
  # id: names the pane's frame instead of fingerprinting it from the query,
@@ -18,12 +27,12 @@ module Janela
18
27
  # limit control) keeps one stable frame for Turbo to reconcile into
19
28
  # rather than a different id every time the query changes (ADR 029).
20
29
  def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, id: nil)
21
- query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
30
+ query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit,
31
+ where: @janela_fixed_filters.presence }.compact
22
32
  base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
23
- src = janela_page_filters.empty? ? base : janela_routes.pane_path(model.model_name.route_key, measure, by, **query, q: janela_page_filters)
24
33
 
25
34
  turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
26
- src: src,
35
+ src: janela_with_page_filters(base),
27
36
  loading: :lazy,
28
37
  data: { janela__frame_target: "pane", janela_src: base }
29
38
  end
@@ -58,6 +67,16 @@ module Janela
58
67
  Janela.scope(controller, model)
59
68
  end
60
69
 
70
+ # The reader's filters appended after everything else in the base URL,
71
+ # which is how the frame controller builds the same URL. Rails sorts
72
+ # query parameters, which would put q before where, and a src spelled
73
+ # differently from the controller's is refetched as stale (#33).
74
+ def janela_with_page_filters(base)
75
+ return base if janela_page_filters.empty?
76
+
77
+ "#{base}#{base.include?("?") ? "&" : "?"}#{{ q: janela_page_filters }.to_query}"
78
+ end
79
+
61
80
  def janela_page_filters
62
81
  @janela_page_filters ||= begin
63
82
  q = request.query_parameters["q"]
@@ -15,6 +15,26 @@ module Janela
15
15
  validates :name, presence: true
16
16
  validates :columns, inclusion: { in: COLUMNS }
17
17
  validates :gap, inclusion: { in: GAPS }
18
+ # The shape of the symbol a host passes, so nothing that reads like a
19
+ # name or a path gets in (ADR 041).
20
+ validates :key, format: { with: /\A[a-z0-9_]+\z/ }, uniqueness: { scope: %i[owner_type owner_id] }, allow_nil: true
21
+
22
+ # The host's frame for this owner and key, created on first use (ADR 041).
23
+ # The block runs only when the frame is created, to set what a new frame
24
+ # starts as. Two requests creating it at once end with one row: the unique
25
+ # index refuses the second insert, and the find is asked again.
26
+ #
27
+ # Janela::Frame.for(account, :overview)
28
+ # Janela::Frame.for(queue, :analytics) { |frame| frame.name = "#{queue.name} analytics" }
29
+ def self.for(owner, key, &block)
30
+ key = key.to_s
31
+ find_or_create_by!(owner: owner, key: key) do |frame|
32
+ frame.name = key.humanize
33
+ block&.call(frame)
34
+ end
35
+ rescue ActiveRecord::RecordNotUnique
36
+ find_by!(owner: owner, key: key)
37
+ end
18
38
 
19
39
  # Positions are kept contiguous so that moving a pane has no gap to fall
20
40
  # into and a new pane's position is never a hole. Called after a pane is
@@ -10,15 +10,55 @@ module Janela
10
10
  # dashboard actually asks for. Any limit inside LIMITS is still valid.
11
11
  OFFERED_LIMITS = [ 5, 10, 20, 50, 100 ].freeze
12
12
 
13
+ # What a pane holds (ADR 039). A query is today's pane. Text is words an
14
+ # analyst writes, escaped. A partial is markup a host wrote in code, which
15
+ # an analyst places by name and hands the same words to.
16
+ KINDS = %w[query text partial].freeze
17
+ # Outside app/views/janela/ on purpose: a host view at an engine's path
18
+ # replaces the engine's own, and janela/panes/ holds the engine's forms.
19
+ CONTENT_PARTIALS = "janela_content"
20
+ PARTIAL_NAME = /\A[a-z0-9_]+\z/
21
+ # A path on this site: one slash, then not a second. A scheme or a
22
+ # protocol relative // would send a reader anywhere under the host's name.
23
+ SITE_PATH = %r{\A/(?!/)}
24
+
13
25
  belongs_to :frame
14
26
 
15
27
  before_validation :assign_position, on: :create
16
28
 
17
29
  validates :position, presence: true
18
- validates :measure, presence: true
30
+ validates :kind, inclusion: { in: KINDS }
19
31
  validates :span, inclusion: { in: SPANS }
20
32
  validates :limit, inclusion: { in: LIMITS }, allow_nil: true
21
- validate :declared_by_a_janela_block
33
+ validates :measure, presence: true, if: :query?
34
+ validate :declared_by_a_janela_block, if: :query?
35
+ validate :holds_no_query, unless: :query?
36
+ validates :heading, presence: true, if: -> { text? && body.blank? }
37
+ validates :link, format: { with: SITE_PATH, message: "must be a path on this site, starting with /" }, allow_blank: true
38
+ validate :names_a_content_partial, if: :partial?
39
+
40
+ # The partials a host has written for analysts to place, by name.
41
+ def self.content_partials
42
+ ActionController::Base.view_paths.flat_map do |path|
43
+ Dir.glob(File.join(path.to_s, CONTENT_PARTIALS, "_*.html.erb")).map { |file| File.basename(file, ".html.erb").delete_prefix("_") }
44
+ end.select { |name| name.match?(PARTIAL_NAME) }.uniq.sort
45
+ end
46
+
47
+ def query?
48
+ kind == "query"
49
+ end
50
+
51
+ def text?
52
+ kind == "text"
53
+ end
54
+
55
+ def partial?
56
+ kind == "partial"
57
+ end
58
+
59
+ def partial_path
60
+ "#{CONTENT_PARTIALS}/#{partial}"
61
+ end
22
62
 
23
63
  # The DOM id is the row, not the query it runs: two rows in one frame may
24
64
  # show the same measure by the same dimension, and a fingerprint of the
@@ -30,15 +70,17 @@ module Janela
30
70
  # renderer: overrides what the row asked for, because a renderer is a
31
71
  # viewing choice and a surface without a chart runtime shows a table
32
72
  # instead (ADR 018).
33
- def query(filters: {}, renderer: self.renderer)
73
+ def query(filters: {}, fixed: {}, renderer: self.renderer)
34
74
  Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
35
- renderer: renderer, granularity: granularity, limit: limit, filters: filters, title: title)
75
+ renderer: renderer, granularity: granularity, limit: limit, filters: filters, fixed: fixed,
76
+ title: title)
36
77
  end
37
78
 
38
79
  # The row's own words, for a list or a heading. Built from the columns
39
80
  # rather than from a query, because an editing page has to render even if
40
81
  # a janela block has since lost the dimension this row names.
41
82
  def label
83
+ return content_label unless query?
42
84
  return title if title.present?
43
85
 
44
86
  dimension.present? ? "#{measure.humanize} by #{dimension.humanize}" : measure.to_s.humanize
@@ -85,6 +127,27 @@ module Janela
85
127
  true
86
128
  end
87
129
 
130
+ def content_label
131
+ return heading if heading.present?
132
+ return partial.humanize if partial?
133
+
134
+ body.to_s.truncate(40)
135
+ end
136
+
137
+ # A row is one thing or the other, so the database never holds half a
138
+ # query that nothing will run.
139
+ def holds_no_query
140
+ %i[model measure dimension granularity].each do |column|
141
+ errors.add(column, "belongs to a query pane, not a #{kind} one") if self[column].present?
142
+ end
143
+ end
144
+
145
+ def names_a_content_partial
146
+ return if partial.to_s.match?(PARTIAL_NAME) && self.class.content_partials.include?(partial)
147
+
148
+ errors.add(:partial, "must name a partial in app/views/#{CONTENT_PARTIALS}/")
149
+ end
150
+
88
151
  def assign_position
89
152
  self.position ||= (frame&.panes&.maximum(:position) || 0) + 1
90
153
  end
@@ -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
- <%= render partial: "janela/frames/pane", collection: frame.panes, as: :pane, locals: { filters: filters, charts: charts } %>
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 %>