janela 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,15 +6,22 @@ engine asks your application two questions and does what it is told.
6
6
 
7
7
  | Question | How your application answers | If it says nothing |
8
8
  | --- | --- | --- |
9
- | What may this request read? | `policy_scope(model)` on the controller Janela inherits | Everything: `model.all` |
9
+ | What may this request read? | `policy_scope(model)` on the controller Janela inherits | Nothing: it raises `Janela::Unscoped` |
10
10
  | What owns a frame being created? | `janela_frame_owner` on the same controller | Nothing: a nil owner |
11
+ | What owns a snapshot being taken? | `owner:`, an argument to `Snapshot.take` | Nothing: a nil owner |
12
+ | What rows does a scheduled snapshot freeze? | `scope:` on `SnapshotJob`, or `scope_for` in a subclass of it | Nothing: it raises `Janela::Unscoped` |
11
13
 
12
- Both are ordinary methods on your `ApplicationController`, found by
13
- duck typing. Pundit defines the first for you. Anything else, you
14
- define in about five lines. ADR 019 has the reasoning for asking
14
+ The first two are ordinary methods on your `ApplicationController`,
15
+ found by duck typing. Pundit defines the first for you. Anything else,
16
+ you define in about five lines. ADR 019 has the reasoning for asking
15
17
  rather than being configured: ownership is per-request state, and a
16
18
  setting cannot hold it.
17
19
 
20
+ The third is an argument rather than a method for the reason ADR 033
21
+ gives: a snapshot is never taken in a request. It is taken in a job, a
22
+ task or a console, where there is no controller to ask and no current
23
+ tenant to ask about, so the caller passes what it already knows.
24
+
18
25
  ## What goes through your scope
19
26
 
20
27
  Everything. There is no path through the engine that reads a record
@@ -104,9 +111,28 @@ class and returns a relation is the whole contract.
104
111
 
105
112
  ## With one tenant
106
113
 
107
- Define nothing. `policy_scope` is absent, every query runs over
108
- `model.all`, and a frame is created with a nil owner because nothing
109
- is filtering on one.
114
+ Write the answer anyway, once:
115
+
116
+ ```ruby
117
+ class ApplicationController < ActionController::Base
118
+ private
119
+ def policy_scope(model) = model.all
120
+ end
121
+ ```
122
+
123
+ Janela will not guess this one. Every other question here takes silence
124
+ as an answer, because the silent answer is the narrow one: no owner, no
125
+ theme, no tenancy. This question's permissive answer is the widest thing
126
+ a library can assume, so it is the one you have to say out loud (ADR
127
+ 032).
128
+
129
+ The line is not ceremony either. It asserts that everyone who can reach
130
+ a dashboard may read every row behind it, and "one tenant" and "no rows
131
+ worth hiding from staff" are not the same claim. If the second is not
132
+ true here, return something narrower.
133
+
134
+ A frame is still created with a nil owner, because nothing is filtering
135
+ on one.
110
136
 
111
137
  ## What owns a frame
112
138
 
@@ -123,20 +149,63 @@ save failed silently. `bin/rails janela:doctor` reports this for you:
123
149
  it asks your policy for a scope over frames and looks for an owner in
124
150
  what comes back.
125
151
 
126
- ## Snapshots are the rough edge
152
+ ## Snapshots
153
+
154
+ A snapshot carries the same polymorphic owner a frame does, so your
155
+ policy has the same column to filter on. You pass it when you take one:
156
+
157
+ ```ruby
158
+ Janela::Snapshot.take(name: "September 2026", owner: ActsAsTenant.current_tenant) do |take|
159
+ take.pane Order, :revenue, on: policy_scope(Order)
160
+ end
161
+ ```
162
+
163
+ ```ruby
164
+ def policy_scope(model)
165
+ case model.name
166
+ when "Janela::Frame", "Janela::Snapshot" then model.where(owner: ActsAsTenant.current_tenant)
167
+ else model.all
168
+ end
169
+ end
170
+ ```
171
+
172
+ `Janela::SnapshotJob` takes `owner:` as well, since ActiveJob carries a
173
+ record across the queue through its GlobalID.
174
+
175
+ ## What a scheduled snapshot freezes
176
+
177
+ The owner says who a snapshot belongs to. What the numbers inside it
178
+ cover is a separate question, and the job asks you rather than guessing.
127
179
 
128
- `janela_snapshots` has no owner column. A snapshot is a name, an
129
- instant, the filters it was taken under and the results. So a multi
130
- tenant application has to scope it by something else:
180
+ A model's default scope is already your tenant's rows if your tenancy is
181
+ enforced on the models, through acts_as_tenant, a `default_scope`, a
182
+ connection or a schema. Say so:
183
+
184
+ ```ruby
185
+ Janela::SnapshotJob.perform_later(name: "September 2026", owner: tenant,
186
+ scope: :model_default, panes: [ ... ])
187
+ ```
188
+
189
+ If your scoping lives in your policies, `model.all` is every row, and no
190
+ symbol can carry the relation you want across a queue. Answer in Ruby.
191
+ Subclass the job, override its one question, and schedule yours:
192
+
193
+ ```ruby
194
+ class TenantSnapshotJob < Janela::SnapshotJob
195
+ private def scope_for(model) = model.where(account: owner)
196
+ end
197
+ ```
131
198
 
132
- - Keep snapshots to one tenant, or to whoever publishes.
133
- - Add your own column with a migration on `janela_snapshots` and scope
134
- on that.
135
- - Encode the tenant in the filters a snapshot is taken under, and
136
- scope on the stored name.
199
+ `name`, `owner` and `filters` are readable inside `scope_for`, so a
200
+ subclass never has to override `perform` or read ActiveJob's arguments.
201
+ Override `scope_for` and the `scope:` argument is not consulted: the
202
+ method that reads it is the one you replaced.
137
203
 
138
- None of those is as clean as a frame's owner. Giving a snapshot the
139
- same polymorphic owner is [issue #32](https://github.com/retail-tasker/janela/issues/32).
204
+ Answer neither way and the job raises `Janela::Unscoped` instead of
205
+ freezing a scope nobody chose. That matters more here than on a live
206
+ page: a wrong pane is wrong once, on a screen, to somebody already
207
+ signed in, while a wrong snapshot is frozen into a row, labelled with an
208
+ owner and served at an address (ADR 034).
140
209
 
141
210
  ## Proving your wiring
142
211
 
data/docs/roadmap.md ADDED
@@ -0,0 +1,97 @@
1
+ ---
2
+ Topics: roadmap, releases, scope, planning
3
+ ---
4
+
5
+ # Where Janela Is Going
6
+
7
+ Janela is alpha. It works, it is tested against a real Rails application
8
+ in a real browser, and its public surface has changed in three of the
9
+ last four releases. This page says what has to be true before that stops,
10
+ what is in the next release, and what is deliberately not coming. The
11
+ reasoning behind it is ADR 037.
12
+
13
+ ## What 1.0 means
14
+
15
+ Not that Janela is finished. ADR 001 commits the project to the
16
+ load-bearing 5% of a BI tool and to staying small enough to fork, so a
17
+ 1.0 measured against what a commercial tool ships would never arrive.
18
+
19
+ 1.0 is three claims you can check.
20
+
21
+ **The public surface stops moving.** Everything listed as the contract in
22
+ [Theming Janela](theming), the measures and dimensions DSL, `janela_pane`
23
+ and `janela_frame`, the pane URL shape and the dashboard filter
24
+ parameters change only on a major version after 1.0. Until then they can
25
+ change in any release, and every change of that kind carries an entry in
26
+ `UPGRADING.md`.
27
+
28
+ **A pane can be read.** Janela draws tables, bars and lines. A
29
+ part-to-whole split currently has to be drawn as bars, a table row cannot
30
+ carry context beside its label, and a chart takes Chart.js's default
31
+ proportions whether or not they suit the page. Those are not extra
32
+ features. They are the 5% not finished, and most of them were found by
33
+ people installing the gem rather than reading it.
34
+
35
+ **The doctor can be trusted.** `rails janela:doctor` checks an
36
+ installation for the mistakes that produce a dashboard showing numbers
37
+ nobody should see. Two of its checks have no test that makes them fire.
38
+ A check nobody can prove is working is a check nobody should rely on.
39
+
40
+ ## In 1.0
41
+
42
+ | Issue | What |
43
+ | --- | --- |
44
+ | [#24](https://github.com/retail-tasker/janela/issues/24) | A chart's height and aspect ratio are the host's to set |
45
+ | [#27](https://github.com/retail-tasker/janela/issues/27) | A ratio measure, so refusing `average:` over a boolean offers somewhere to go |
46
+ | [#30](https://github.com/retail-tasker/janela/issues/30) | Doughnut and pie, and a categorical palette that makes them readable |
47
+ | [#31](https://github.com/retail-tasker/janela/issues/31) | A pane says how prominent it is |
48
+ | [#34](https://github.com/retail-tasker/janela/issues/34) | A table pane carries an attribute column beside its label |
49
+ | [#53](https://github.com/retail-tasker/janela/issues/53) | The last two doctor checks get tests |
50
+ | [#14](https://github.com/retail-tasker/janela/issues/14) | A Sprockets host serves the engine's JavaScript, or is told it cannot |
51
+ | [#6](https://github.com/retail-tasker/janela/issues/6) | How contributions are accepted, who cuts a release, where to report a vulnerability |
52
+
53
+ Progress is tracked on the
54
+ [1.0 milestone](https://github.com/retail-tasker/janela/milestone/2).
55
+
56
+ ## After 1.0
57
+
58
+ Wanted, not blocking a stable release. Being on this list is not a
59
+ refusal.
60
+
61
+ - **Drill-down on time panes** ([#18](https://github.com/retail-tasker/janela/issues/18)).
62
+ Clicking a month could filter every other pane to it, or narrow that
63
+ pane to weeks within it. Both are reasonable, they need different
64
+ things from the URL, and ADR 006 left the question open on purpose. It
65
+ needs a decision record before any code.
66
+ - **A command palette for the demo** ([#41](https://github.com/retail-tasker/janela/issues/41)).
67
+ The demo site, not the gem.
68
+ - **A scroll drift on the gallery page** ([#45](https://github.com/retail-tasker/janela/issues/45)).
69
+ Cosmetic, demo only, and possibly not worth fixing.
70
+
71
+ ## Not coming
72
+
73
+ Janela is meant to be small enough that forking it and adding your own
74
+ piece is a normal way to use it. These are the things you would be
75
+ adding yourself, and each is left out because something you already run
76
+ does it better.
77
+
78
+ Natural-language query. A separate data warehouse. A row-level-security
79
+ subsystem, because your application already has Pundit or CanCanCan and
80
+ Janela reads through it. A refresh-scheduling interface, because you
81
+ already have a scheduler and snapshots are an ActiveJob. An embedding
82
+ SDK. A mobile application. Print and paginated reports. A drag-and-drop
83
+ visual dashboard designer, though frames and panes are database records,
84
+ so an application can build its own editor on top of them.
85
+
86
+ Whether Janela should help arrange panes on a page is genuinely open
87
+ ([#29](https://github.com/retail-tasker/janela/issues/29)). The default
88
+ answer is that layout belongs to your application, and changing it would
89
+ need a decision record first.
90
+
91
+ ## How this page stays honest
92
+
93
+ It is updated when a release lands, not on a schedule, and it carries no
94
+ dates. A roadmap with dates on a project this size would be wrong within
95
+ a fortnight and would train you to ignore it. If something here has been
96
+ true for a long time and nothing has moved, that is worth reading as the
97
+ signal it is.
data/docs/theming.md ADDED
@@ -0,0 +1,177 @@
1
+ ---
2
+ Topics: styling, theming, css, host-integration
3
+ ---
4
+
5
+ # Theming Janela
6
+
7
+ Janela publishes the hooks; a theme supplies the taste. This page is the
8
+ contract: the class names and custom properties the engine renders, which
9
+ your own stylesheet or somebody else's theme may target, and which will
10
+ not change without an entry in `UPGRADING.md` (ADR 036).
11
+
12
+ Two stylesheets ship in the gem.
13
+
14
+ ```erb
15
+ <%= stylesheet_link_tag "janela" %> <%# the hooks, and enough style to be legible %>
16
+ <%= stylesheet_link_tag "vitral" %> <%# optional: one theme, stained glass %>
17
+ ```
18
+
19
+ `janela.css` is not a look. It is the grid, the pane's own markup and
20
+ enough base styling that a table does not run its label into its number.
21
+ Everything decorative is a theme's, and vitral is one theme rather than
22
+ the theme.
23
+
24
+ ## The contract
25
+
26
+ ### Custom properties
27
+
28
+ Three, and setting them moves everything that depends on them.
29
+
30
+ | Property | Default | What it does |
31
+ | --- | --- | --- |
32
+ | `--janela-space` | `0.25rem` | The base unit of the whole spacing scale. Every gap and padding is a multiple of it. |
33
+ | `--janela-line` | `rgba(128, 128, 128, 0.3)` | Rules between rows, borders on cards and fields. |
34
+ | `--janela-accent` | `rgb(54, 162, 235)` | A selected value, a hovered card. |
35
+
36
+ ```css
37
+ :root {
38
+ --janela-space: 0.3rem;
39
+ --janela-accent: #7c3aed;
40
+ }
41
+ ```
42
+
43
+ ### The grid
44
+
45
+ A frame's stored integers choose these. Nothing an analyst types reaches
46
+ CSS as a length: the number selects a rule that is already written
47
+ (ADR 016).
48
+
49
+ | Class | Range |
50
+ | --- | --- |
51
+ | `janela-frame` | the grid container itself |
52
+ | `janela-cols-N` | 1 to 12 |
53
+ | `janela-gap-N` | 0 to 8, multiplied by `--janela-space` |
54
+ | `janela-span-N` | 1 to 12, on a pane |
55
+
56
+ Below `40rem` the grid collapses to one column. That is in the
57
+ stylesheet rather than in the data, because a dashboard nobody can read
58
+ on a phone is not a choice worth offering.
59
+
60
+ ### The pane
61
+
62
+ What `janela_pane` and `janela_frame` put in your own pages. These are
63
+ the names a theme spends most of its time on.
64
+
65
+ | Class | On | Rendered when |
66
+ | --- | --- | --- |
67
+ | `janela-pane` | `<table>`, `<p>` or `<canvas>` | every pane, whatever the renderer |
68
+ | `janela-value` | `<p>` | a single value pane |
69
+ | `janela-value-label` | `<span>` | its caption |
70
+ | `janela-value-number` | `<strong>` | the number itself |
71
+ | `janela-chart` | `<canvas>` | a bar or line pane |
72
+ | `janela-empty` | `<p>` | a pane whose query returned nothing |
73
+ | `janela-error` | `<p>` | a pane that could not be read |
74
+
75
+ A table pane also renders a `<caption>`, its accessible name, and a
76
+ chart pane carries the same string as `aria-label`.
77
+
78
+ ### Hiding a heading you already wrote
79
+
80
+ Put `janela-own-headings` on any ancestor and a pane's caption and a
81
+ single value's label are hidden from sight while staying in the
82
+ accessibility tree.
83
+
84
+ ```erb
85
+ <div class="janela-own-headings">
86
+ <h3>Revenue by status</h3>
87
+ <%= janela_pane Order, :revenue, by: :status %>
88
+ </div>
89
+ ```
90
+
91
+ Use it when your own markup already says what the pane is, so the text
92
+ is not on screen twice. It hides rather than removes on purpose: a table
93
+ with no caption has no accessible name, so a screen reader lands on a
94
+ grid of numbers with nothing to say what they measure. `display: none`
95
+ would do that, which is why this rule ships here rather than being left
96
+ for each host to write.
97
+
98
+ A chart pane needs nothing: its title is only ever an `aria-label` and
99
+ was never drawn.
100
+
101
+ ## What is not the contract
102
+
103
+ `janela.css` also styles Janela's own pages, the frame index and the
104
+ editing forms: `janela-page`, `janela-card`, `janela-button`,
105
+ `janela-form`, `janela-field`, `janela-list`, `janela-crumb`,
106
+ `janela-flash` and the rest. They are scoped under `janela-page`, which
107
+ only the engine's own layout sets, so they cannot touch your pages.
108
+
109
+ **Those names may change in any release.** They are the engine's own
110
+ chrome rather than an interface. Restyle them if you want Janela's
111
+ pages to match your application, and expect to revisit it after an
112
+ upgrade; or point `Janela.theme` at a stylesheet of your own, below.
113
+
114
+ ## Writing a theme
115
+
116
+ A theme is any stylesheet, named once:
117
+
118
+ ```ruby
119
+ # config/initializers/janela.rb
120
+ Janela.theme = "midnight"
121
+ ```
122
+
123
+ The name is resolved against your own asset paths, so `midnight.css` in
124
+ your application works exactly as the gem's own `vitral` does. A name
125
+ that resolves to nothing raises rather than quietly rendering an
126
+ unthemed page.
127
+
128
+ That setting links the theme into **Janela's own pages**. Your pages load
129
+ whatever your layout says, so if you want the same look around a pane you
130
+ have embedded yourself, link the stylesheet there too. The asymmetry is
131
+ deliberate: Janela is an isolated engine and does not write your layout
132
+ (ADR 011).
133
+
134
+ A theme targets the contract above and nothing else. If it cannot be
135
+ written that way, the contract is missing something, which is worth an
136
+ issue rather than a workaround.
137
+
138
+ ## Vitral
139
+
140
+ The theme the gem ships. A *vitral* is a stained glass window: each pane
141
+ holds one of five colours, dark leading runs between them, and the light
142
+ comes from behind.
143
+
144
+ ```erb
145
+ <%= stylesheet_link_tag "vitral" %>
146
+ <body class="vitral">
147
+ ```
148
+
149
+ Nothing is repainted until that class is present, so linking the
150
+ stylesheet can never be the thing that broke a page.
151
+
152
+ **Its own public classes**, so the page around a dashboard can be made of
153
+ the same window: `vitral-pane`, `vitral-panes`, `vitral-button`,
154
+ `vitral-button-primary`, `vitral-lattice`.
155
+
156
+ **Retheme it from its custom properties** rather than by forking it. The
157
+ light is `--vitral-light-cobalt`, `-teal`, `-amber`, `-rose`, `-violet`;
158
+ the glass is `--vitral-pane-1` through `-5`; the leading is
159
+ `--vitral-came`; and `--vitral-ink`, `--vitral-muted`, `--vitral-ground`,
160
+ `--vitral-radius`, `--vitral-shadow` and `--vitral-blur` do what they
161
+ say.
162
+
163
+ ```css
164
+ :root {
165
+ --vitral-pane-1: rgba(120, 60, 200, 0.3);
166
+ --vitral-came: #1b1b1b;
167
+ }
168
+ ```
169
+
170
+ **It works without JavaScript.** Janela's own pages load none (ADR 011),
171
+ so the stained glass is CSS and the lattice that leans toward the pointer
172
+ is a separate optional controller. Reduced motion, reduced transparency
173
+ and increased contrast each fall back to a still, solid window, and so
174
+ does a browser without `backdrop-filter`.
175
+
176
+ `test/dummy` has a live page at `/vitral` showing all of it against real
177
+ panes.
@@ -6,8 +6,15 @@ module Janela
6
6
 
7
7
  attr_reader :model, :measures, :dimensions
8
8
 
9
+ # The class whose janela block this is, which a subclass shares with the
10
+ # parent it inherited from. A subclass gets a definition of its own, so
11
+ # anything reporting on a declaration rather than on a model groups by
12
+ # this or says the same thing once per class in an STI family (ADR 035).
13
+ attr_accessor :declared_by
14
+
9
15
  def initialize(model, &block)
10
16
  @model = model
17
+ @declared_by = model
11
18
  @measures = {}
12
19
  @dimensions = {}
13
20
  @block = block
@@ -19,7 +26,7 @@ module Janela
19
26
  # the queries they run are its own, because ActiveRecord adds the type
20
27
  # condition to a relation on the subclass (ADR 031).
21
28
  def for(model)
22
- self.class.new(model, &@block)
29
+ self.class.new(model, &@block).tap { |inherited| inherited.declared_by = declared_by }
23
30
  end
24
31
 
25
32
  def measure(name, **aggregate)