janela 0.1.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 +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +185 -0
- data/Rakefile +23 -0
- data/app/assets/javascripts/janela/chart_controller.js +44 -0
- data/app/assets/javascripts/janela/dashboard_controller.js +40 -0
- data/app/assets/javascripts/janela/vendor/chart.js +27 -0
- data/app/controllers/janela/application_controller.rb +11 -0
- data/app/controllers/janela/visuals_controller.rb +21 -0
- data/app/helpers/janela/dashboard_helper.rb +16 -0
- data/app/models/janela/visual.rb +65 -0
- data/app/views/janela/visuals/show.html.erb +36 -0
- data/config/importmap.rb +5 -0
- data/config/routes.rb +3 -0
- data/docs/decisions/001-built-to-be-forked.md +73 -0
- data/docs/decisions/002-measures-and-dimensions-over-ransack.md +125 -0
- data/docs/decisions/003-cross-filtering-with-turbo-frames.md +104 -0
- data/docs/decisions/004-charts-and-javascript-delivery.md +135 -0
- data/docs/decisions/INDEX.md +44 -0
- data/lib/janela/definition.rb +70 -0
- data/lib/janela/dimension.rb +30 -0
- data/lib/janela/engine.rb +21 -0
- data/lib/janela/measure.rb +26 -0
- data/lib/janela/model.rb +25 -0
- data/lib/janela/version.rb +5 -0
- data/lib/janela.rb +39 -0
- metadata +133 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# One measure grouped by one dimension, rendered as a table or a chart. A
|
|
3
|
+
# visual ignores filters on its own dimension: clicking a value in a visual
|
|
4
|
+
# should re-scope the others, not collapse itself to the value clicked.
|
|
5
|
+
class Visual
|
|
6
|
+
RENDERERS = %w[table bar].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :definition, :measure, :dimension, :renderer, :filters
|
|
9
|
+
|
|
10
|
+
# The helper renders the frame and the controller renders its replacement,
|
|
11
|
+
# so both derive the id the same way from the same parameters.
|
|
12
|
+
def self.frame_id(model:, measure:, by:, as: :table)
|
|
13
|
+
"janela_#{model.to_s.underscore}_#{measure}_by_#{by}_#{as}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(definition:, measure:, dimension:, renderer: "table", filters: {})
|
|
17
|
+
@definition = definition
|
|
18
|
+
@measure = measure
|
|
19
|
+
@dimension = dimension
|
|
20
|
+
@renderer = renderer.to_s
|
|
21
|
+
@filters = filters
|
|
22
|
+
|
|
23
|
+
raise Error, "unknown visual renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def model
|
|
27
|
+
definition.model
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def chart?
|
|
31
|
+
renderer != "table"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def frame_id
|
|
35
|
+
self.class.frame_id(model: model.name, measure: measure, by: dimension, as: renderer)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def title
|
|
39
|
+
"#{measure.to_s.humanize} by #{dimension.to_s.humanize}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def result(on: nil)
|
|
43
|
+
definition.query(measure, by: dimension, where: applicable_filters, on: on)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def filter_key
|
|
47
|
+
"#{ransack_name}_eq"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The filter on this visual's own dimension is not applied to its query,
|
|
51
|
+
# but it is what the user clicked here, so the view highlights it.
|
|
52
|
+
def selected_value
|
|
53
|
+
filters[filter_key] || filters[filter_key.to_sym]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
def ransack_name
|
|
58
|
+
definition.dimension!(dimension).ransack_name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def applicable_filters
|
|
62
|
+
filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%= turbo_frame_tag @visual.frame_id do %>
|
|
2
|
+
<% if @result.empty? %>
|
|
3
|
+
<p class="janela-visual janela-empty"><%= @visual.title %>: no data</p>
|
|
4
|
+
<% elsif @visual.chart? %>
|
|
5
|
+
<canvas class="janela-visual janela-chart"
|
|
6
|
+
data-controller="janela--chart"
|
|
7
|
+
data-action="janela--chart:toggle->janela--dashboard#toggle"
|
|
8
|
+
data-janela--chart-type-value="<%= @visual.renderer %>"
|
|
9
|
+
data-janela--chart-title-value="<%= @visual.title %>"
|
|
10
|
+
data-janela--chart-key-value="<%= @visual.filter_key %>"
|
|
11
|
+
data-janela--chart-selected-value="<%= @visual.selected_value %>"
|
|
12
|
+
data-janela--chart-labels-value="<%= @result.keys.to_json %>"
|
|
13
|
+
data-janela--chart-values-value="<%= @result.values.map(&:to_f).to_json %>"
|
|
14
|
+
role="img" aria-label="<%= @visual.title %>"></canvas>
|
|
15
|
+
<% else %>
|
|
16
|
+
<table class="janela-visual">
|
|
17
|
+
<caption><%= @visual.title %></caption>
|
|
18
|
+
<tbody>
|
|
19
|
+
<% @result.each do |value, measured| %>
|
|
20
|
+
<tr>
|
|
21
|
+
<td>
|
|
22
|
+
<button type="button"
|
|
23
|
+
aria-pressed="<%= value.to_s == @visual.selected_value.to_s && @visual.selected_value.present? %>"
|
|
24
|
+
data-action="janela--dashboard#toggle"
|
|
25
|
+
data-janela--dashboard-key-param="<%= @visual.filter_key %>"
|
|
26
|
+
data-janela--dashboard-value-param="<%= value %>">
|
|
27
|
+
<%= value %>
|
|
28
|
+
</button>
|
|
29
|
+
</td>
|
|
30
|
+
<td><%= number_with_delimiter(measured) %></td>
|
|
31
|
+
</tr>
|
|
32
|
+
<% end %>
|
|
33
|
+
</tbody>
|
|
34
|
+
</table>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
pin "janela/dashboard_controller", to: "janela/dashboard_controller.js"
|
|
2
|
+
pin "janela/chart_controller", to: "janela/chart_controller.js"
|
|
3
|
+
|
|
4
|
+
# Hosts that already pin their own Chart.js keep it.
|
|
5
|
+
pin "chart.js", to: "janela/vendor/chart.js" unless packages.key?("chart.js")
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-11
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Triggers:
|
|
5
|
+
- adding a feature or configuration option to Janela
|
|
6
|
+
- a design decision that touches a specific host application's domain or business logic
|
|
7
|
+
- reviewing a contribution (human or agent-authored)
|
|
8
|
+
- deciding how much flexibility/configurability a new API surface should expose
|
|
9
|
+
Topics: vision, scope, forkability, open-source, host-decoupling
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# ADR 001: Built to Be Forked
|
|
13
|
+
|
|
14
|
+
## Context
|
|
15
|
+
|
|
16
|
+
Janela is a standalone open-source Rails gem: business-intelligence-
|
|
17
|
+
style dashboards and cross-filtering on ActiveRecord models. It is
|
|
18
|
+
being built and open-sourced deliberately, with the aspiration of
|
|
19
|
+
presenting it to the wider Rails community.
|
|
20
|
+
|
|
21
|
+
Commercial BI dashboard tools' actual surface area is mostly enterprise
|
|
22
|
+
packaging. The load-bearing 5%, declarative measures and dimensions
|
|
23
|
+
over a data model plus cross-filtering, is the real gap in the Rails BI
|
|
24
|
+
ecosystem. Every other feature a BI tool "should" have (drag-drop
|
|
25
|
+
designer, NL query, RLS subsystem, embedding SDK, etc.) is either
|
|
26
|
+
bloat or something Rails/Pundit/ActiveJob already own better.
|
|
27
|
+
|
|
28
|
+
Separately, DHH's framing in his September 2026 Lex Fridman interview
|
|
29
|
+
(#501, "Future of Programming, AI, Agentic Engineering, Vibe Coding &
|
|
30
|
+
Linux", https://lexfridman.com/dhh-2-transcript/) gave language to an
|
|
31
|
+
approach already implicit in the scope decision: agents make it
|
|
32
|
+
economically viable for a consumer to fork a small tool and keep only
|
|
33
|
+
the 5% they need, rather than adopt a large configurable one wholesale.
|
|
34
|
+
He also argued that overly prescriptive project instructions actively
|
|
35
|
+
damage agent output. Describe the problem, not the solution.
|
|
36
|
+
|
|
37
|
+
## Decision
|
|
38
|
+
|
|
39
|
+
Janela is designed to be forked, not just configured.
|
|
40
|
+
|
|
41
|
+
1. **Ship only the load-bearing 5%.** Measures/dimensions as a thin
|
|
42
|
+
Ruby DSL over Ransack (not a new query language), cross-filtering
|
|
43
|
+
via a Stimulus controller + Turbo Frames (not a JS framework).
|
|
44
|
+
Everything else stays out of scope.
|
|
45
|
+
2. **Prefer one obvious way to do a thing over configurable
|
|
46
|
+
flexibility.** Every added config flag is a fork someone didn't
|
|
47
|
+
need to make. Default to fewer knobs, not more.
|
|
48
|
+
3. **Stay fully decoupled from any host application.** No
|
|
49
|
+
application-specific code, model names, table shapes, or business
|
|
50
|
+
logic in this repo, ever. Host applications depend on Janela;
|
|
51
|
+
Janela never depends on a host application.
|
|
52
|
+
4. **Document decisions as ADRs in this repo**, so a forker
|
|
53
|
+
understands *why* a piece exists before they rip it out or
|
|
54
|
+
replace it, not just what it does.
|
|
55
|
+
5. **Treat agent-authored contributions the same as human ones.**
|
|
56
|
+
Reviewed on the merits of the diff, not the source.
|
|
57
|
+
6. **Keep project instructions (CLAUDE.md) minimal and intent-focused**
|
|
58
|
+
rather than prescriptive, consistent with the reasoning above.
|
|
59
|
+
|
|
60
|
+
## Consequences
|
|
61
|
+
|
|
62
|
+
- Feature requests that only make sense as a config flag for one
|
|
63
|
+
user's edge case should default to "fork it" rather than "add a
|
|
64
|
+
flag." This will read as less accommodating than a typical OSS
|
|
65
|
+
project and is intentional.
|
|
66
|
+
- The codebase must stay small and legible enough that forking a
|
|
67
|
+
chunk is realistic. This is a constant pressure against adding
|
|
68
|
+
abstractions, even useful-seeming ones.
|
|
69
|
+
- Any specific host application's needs get solved in that
|
|
70
|
+
application's own codebase against Janela's public API, never by
|
|
71
|
+
special-casing Janela itself.
|
|
72
|
+
- Future scope decisions (what's in the 5%, what isn't) should cite
|
|
73
|
+
this ADR rather than re-litigate the philosophy each time.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-13
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding or changing the measures/dimensions DSL
|
|
7
|
+
- deciding how a dashboard filter reaches the query layer
|
|
8
|
+
- adding a dependency to the query path
|
|
9
|
+
- wiring authorisation around a dashboard query
|
|
10
|
+
- adding time-granularity or drill-down dimensions
|
|
11
|
+
Topics: dsl, query-layer, ransack, dependencies, authorisation
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 002: Measures and Dimensions over Ransack
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
ADR 001 committed to shipping the load-bearing core: declarative
|
|
19
|
+
measures/dimensions, and cross-filtering. This ADR covers the first
|
|
20
|
+
half, spiked against real ActiveRecord models in `test/dummy`.
|
|
21
|
+
|
|
22
|
+
Two questions had to be answered by building rather than arguing.
|
|
23
|
+
|
|
24
|
+
**What sits under the DSL?** The spike first hid Ransack behind
|
|
25
|
+
Janela's own filter vocabulary (`where: { region: "EU" }`), translating
|
|
26
|
+
dimension names into Ransack predicates. That worked, but the
|
|
27
|
+
familiarity a Rails developer gets from Ransack was spent entirely
|
|
28
|
+
inside Janela. The host still inherited Ransack's constraints while
|
|
29
|
+
touching none of its API. Plain ActiveRecord was the obvious
|
|
30
|
+
alternative, since every Janela filter is only ever "dimension in
|
|
31
|
+
values".
|
|
32
|
+
|
|
33
|
+
**How does authorisation get in?** ADR 001 already put a
|
|
34
|
+
row-level-security subsystem out of scope. The open question was
|
|
35
|
+
whether to depend on Pundit directly.
|
|
36
|
+
|
|
37
|
+
A survey of the filtering ecosystem informed the first question.
|
|
38
|
+
Ransack has roughly 115M downloads against 1.9M for the next
|
|
39
|
+
most-used option, so it is the only filter gem that a Rails developer
|
|
40
|
+
can be assumed to already know. Notably, none of the filter gems do
|
|
41
|
+
aggregation. The `GROUP BY` half of Janela is Janela's own code
|
|
42
|
+
regardless of what sits underneath the filter half.
|
|
43
|
+
|
|
44
|
+
## Decision
|
|
45
|
+
|
|
46
|
+
**The DSL is a `janela` block on the model.**
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
class Order < ApplicationRecord
|
|
50
|
+
belongs_to :customer
|
|
51
|
+
|
|
52
|
+
janela do
|
|
53
|
+
measure :revenue, sum: :amount
|
|
54
|
+
measure :orders, count: true
|
|
55
|
+
dimension :status
|
|
56
|
+
dimension :region, through: :customer
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A measure takes exactly one aggregate of `sum`, `count`, `average`,
|
|
62
|
+
`minimum`, `maximum`. A dimension is a column on the model, or a
|
|
63
|
+
column on an association via `through:`.
|
|
64
|
+
|
|
65
|
+
**Filters are Ransack params, passed through rather than translated.**
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
Order.janela.query(:revenue, by: :status, where: { customer_region_in: %w[APAC EU] })
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The host can hand `params[:q]` from a standard `search_form_for`
|
|
72
|
+
slicer straight to Janela with no translation layer, and the
|
|
73
|
+
cross-filter controller emits predicate names every Rails developer
|
|
74
|
+
already reads fluently. Ransack is a runtime dependency.
|
|
75
|
+
|
|
76
|
+
**Dimensions define the Ransack allowlist.** Declaring a dimension is
|
|
77
|
+
declaring that the attribute is filterable, so Janela generates
|
|
78
|
+
`ransackable_attributes` and `ransackable_associations` on the model.
|
|
79
|
+
A model that already declares its own keeps it.
|
|
80
|
+
|
|
81
|
+
**A dropped filter raises.** Ransack silently discards conditions its
|
|
82
|
+
allowlist does not permit. For a BI tool that means quietly returning
|
|
83
|
+
unfiltered numbers that look filtered, so Janela verifies every
|
|
84
|
+
supplied filter was applied and raises `Janela::Error` otherwise.
|
|
85
|
+
|
|
86
|
+
**Authorisation is a hook, not a dependency.** `query` accepts `on:`,
|
|
87
|
+
any relation, defaulting to `model.all`:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Order.janela.query(:revenue, on: policy_scope(Order))
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Pundit users recognise that line; CanCanCan users pass
|
|
94
|
+
`Order.accessible_by(current_ability)`. Janela does not depend on
|
|
95
|
+
either.
|
|
96
|
+
|
|
97
|
+
## Consequences
|
|
98
|
+
|
|
99
|
+
- Janela's public filter API is coupled to Ransack's predicate naming.
|
|
100
|
+
Replacing the filter engine later is a breaking change. Accepted
|
|
101
|
+
deliberately: ADR 001 prefers one obvious way, and Ransack is the
|
|
102
|
+
obvious Rails way to filter.
|
|
103
|
+
- **A `through:` dimension requires the associated model to allowlist
|
|
104
|
+
the attribute itself.** Ransack's allowlist is per-class, so Janela
|
|
105
|
+
can only own the allowlist of the model the dimensions are declared
|
|
106
|
+
on. Associated models need both `ransackable_attributes` and
|
|
107
|
+
`ransackable_associations` defined or Ransack raises. This must be
|
|
108
|
+
documented prominently; it is the most likely source of confusion
|
|
109
|
+
for a first-time user.
|
|
110
|
+
- Filtering and grouping on the same association produces a redundant
|
|
111
|
+
aliased join, because Ransack builds its own join regardless of one
|
|
112
|
+
already being present. Results are correct; the cost will compound
|
|
113
|
+
as dashboards add through-dimensions. Revisit if it shows up in real
|
|
114
|
+
query plans, not before.
|
|
115
|
+
- Aggregation stays Janela's own code. No filter gem offers it, so
|
|
116
|
+
nothing in this decision reduces the surface area Janela maintains
|
|
117
|
+
for measures.
|
|
118
|
+
- Time-granularity dimensions (`granularity: :day`) are not built.
|
|
119
|
+
When they are, Groupdate is the conventional answer and should be
|
|
120
|
+
evaluated then. Time-zone-correct bucketing is genuinely fiddly,
|
|
121
|
+
and worth a dependency in a way filtering was not.
|
|
122
|
+
- Active Search, the search framework 37signals is introducing at
|
|
123
|
+
Rails World 2026, is text search across swappable engines and does
|
|
124
|
+
not overlap this decision. Worth revisiting only if it ships facet
|
|
125
|
+
counts, which are adjacent to slicer counts.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-13
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001, ADR 002
|
|
5
|
+
Triggers:
|
|
6
|
+
- changing how filter state is shared between visuals
|
|
7
|
+
- adding a visual type, or changing what a visual renders
|
|
8
|
+
- exposing a model or query over HTTP
|
|
9
|
+
- adding authentication or authorisation to dashboards
|
|
10
|
+
- deciding whether a behaviour needs a browser test
|
|
11
|
+
Topics: cross-filtering, stimulus, turbo, security, authorisation, testing
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 003: Cross-filtering with Turbo Frames
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
Cross-filtering is the reason Janela exists. No gem in the Rails
|
|
19
|
+
ecosystem lets clicking a value in one visual re-scope every other
|
|
20
|
+
visual on the page, and ADR 001 named it one of the two things worth
|
|
21
|
+
building.
|
|
22
|
+
|
|
23
|
+
The obvious implementations all pull in machinery Janela does not
|
|
24
|
+
want: a JS framework holding client state, Turbo Streams broadcasting
|
|
25
|
+
updates, or a WebSocket. Each would contradict the first principle of
|
|
26
|
+
no JS framework and no build step.
|
|
27
|
+
|
|
28
|
+
Exposing visuals over HTTP also raises a question the DSL did not.
|
|
29
|
+
A visual is identified by a model, a measure and a dimension, and
|
|
30
|
+
those arrive as request parameters, so something has to stop a
|
|
31
|
+
parameter naming an arbitrary class.
|
|
32
|
+
|
|
33
|
+
## Decision
|
|
34
|
+
|
|
35
|
+
**A visual is a Turbo Frame whose `src` carries the filters.** Turbo
|
|
36
|
+
reloads a frame whenever its `src` attribute changes, so a Stimulus
|
|
37
|
+
controller holding shared filter state only has to rewrite each
|
|
38
|
+
frame's `src`:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
filtersValueChanged() {
|
|
42
|
+
this.visualTargets.forEach((visual) => {
|
|
43
|
+
const url = new URL(visual.dataset.janelaSrc, window.location.origin)
|
|
44
|
+
for (const [key, value] of Object.entries(this.filtersValue)) {
|
|
45
|
+
url.searchParams.set(`q[${key}]`, value)
|
|
46
|
+
}
|
|
47
|
+
if (visual.src !== url.href) visual.src = url.href
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
No streams, no sockets, no state library. Filters are Ransack params
|
|
53
|
+
per ADR 002, so they stay readable and shareable in the URL. Clicking
|
|
54
|
+
a value already applied removes it.
|
|
55
|
+
|
|
56
|
+
**A visual ignores filters on its own dimension.** Otherwise clicking
|
|
57
|
+
"paid" in a revenue-by-status visual collapses that visual to the
|
|
58
|
+
single bar that was clicked. The rule is per-dimension rather than
|
|
59
|
+
per-visual: a visual grouped by region ignores region filters even
|
|
60
|
+
when a different visual originated them, so region totals stay
|
|
61
|
+
comparable. This is one line in `Janela::Visual` and needs no
|
|
62
|
+
tracking of which visual a filter came from.
|
|
63
|
+
|
|
64
|
+
**Only models that declare a `janela` block are addressable.**
|
|
65
|
+
Declaring the block registers the model's name, and the registry is
|
|
66
|
+
a strict allowlist, so a request parameter can never constantize an
|
|
67
|
+
arbitrary class. Names are stored rather than class objects so a
|
|
68
|
+
reloaded model leaves nothing stale behind. A lookup that misses in
|
|
69
|
+
development calls `eager_load!` rather than constantizing the
|
|
70
|
+
parameter to find out whether it is valid.
|
|
71
|
+
|
|
72
|
+
**Authorisation needs no configuration.** `Janela::ApplicationController`
|
|
73
|
+
inherits from `Janela.parent_controller` (the host's
|
|
74
|
+
`ApplicationController` by default), so the host's authentication
|
|
75
|
+
filters already apply. Scoping calls `policy_scope` when the host
|
|
76
|
+
defined it and falls back to `model.all`, which means Pundit users
|
|
77
|
+
get authorisation automatically without Janela depending on Pundit.
|
|
78
|
+
|
|
79
|
+
**Cross-filtering gets a browser test.** Request tests cannot prove
|
|
80
|
+
that clicking re-renders other frames, and that behaviour is the
|
|
81
|
+
product. `test/system/cross_filtering_test.rb` drives real Chrome;
|
|
82
|
+
CI runs it as a separate job so the unit matrix stays fast.
|
|
83
|
+
|
|
84
|
+
## Consequences
|
|
85
|
+
|
|
86
|
+
- Each filter change refetches every visual, one request per frame.
|
|
87
|
+
Fine at the scale a dashboard page renders; a page with many
|
|
88
|
+
visuals will want debouncing or a combined endpoint later. Do not
|
|
89
|
+
optimise before a real dashboard shows the problem.
|
|
90
|
+
- Filter state lives in frame `src` attributes, not in the page URL,
|
|
91
|
+
so the browser back button does not step through filter changes and
|
|
92
|
+
a filtered dashboard cannot yet be shared as a link. Promoting
|
|
93
|
+
filter state to the page URL is the obvious next step and was left
|
|
94
|
+
out deliberately.
|
|
95
|
+
- The host registers the Stimulus controller explicitly
|
|
96
|
+
(`application.register("janela--dashboard", JanelaDashboardController)`).
|
|
97
|
+
One documented line, rather than Janela reaching into the host's
|
|
98
|
+
Stimulus instance.
|
|
99
|
+
- Visuals render as tables. Charts wrap an existing library off the
|
|
100
|
+
same server-supplied values and change nothing about the mechanism.
|
|
101
|
+
- `turbo-rails` and `stimulus-rails` become runtime dependencies,
|
|
102
|
+
which is what "built on Ruby and Stimulus" already implied.
|
|
103
|
+
- CI now needs Chrome for one job. Accepted because the alternative
|
|
104
|
+
is that the product's central claim is only ever verified by hand.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-15
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001, ADR 003
|
|
5
|
+
Triggers:
|
|
6
|
+
- changing how Janela's JavaScript reaches a host application
|
|
7
|
+
- adding or changing a chart type or the chart controller
|
|
8
|
+
- adding a JavaScript dependency
|
|
9
|
+
- a host reporting that no Janela controller connects
|
|
10
|
+
- securing or authenticating dashboard endpoints
|
|
11
|
+
Topics: javascript, npm, importmap, jsbundling, charts, chart.js, authorisation, packaging
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 004: Charts and JavaScript Delivery
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
ADR 003 delivered Janela's Stimulus controller through the engine's
|
|
19
|
+
`config/importmap.rb`. Installing the gem into a real host application
|
|
20
|
+
for the v0.1.0 alpha showed that this reaches only importmap hosts. A
|
|
21
|
+
host that bundles JavaScript with esbuild, bun or webpack (jsbundling)
|
|
22
|
+
has no importmap, so the engine's pin is silently skipped and none of
|
|
23
|
+
Janela's JavaScript loads. The Turbo Frames still render, so the
|
|
24
|
+
failure is quiet: everything looks right and nothing cross-filters.
|
|
25
|
+
|
|
26
|
+
The alpha also required charts. Two questions had to be settled at
|
|
27
|
+
once: how a chart library reaches both kinds of host, and how a click
|
|
28
|
+
on a chart element becomes the same filter toggle a table button
|
|
29
|
+
already emits.
|
|
30
|
+
|
|
31
|
+
A third finding came from the same install. `Janela::ApplicationController`
|
|
32
|
+
inherits from the host's `ApplicationController`, which is only as
|
|
33
|
+
authenticated as the host makes it. In a host that authenticates per
|
|
34
|
+
controller, Janela's endpoints were public, and a host filter that
|
|
35
|
+
redirected to the sign-in page resolved its route against the engine's
|
|
36
|
+
route set rather than the host's.
|
|
37
|
+
|
|
38
|
+
## Decision
|
|
39
|
+
|
|
40
|
+
**Janela ships as a gem and an npm package, from the same repository.**
|
|
41
|
+
The gem carries the engine; a root `package.json` exposes the same
|
|
42
|
+
Stimulus controllers as `@retail-tasker/janela` with `exports` for
|
|
43
|
+
`./dashboard_controller` and `./chart_controller`. Both halves install
|
|
44
|
+
from GitHub with no registry publish:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
gem "janela", github: "retail-tasker/janela"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
yarn add github:retail-tasker/janela
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The package name is scoped because `janela` is already taken on npm,
|
|
55
|
+
and because that is the convention Rails-adjacent packages follow
|
|
56
|
+
(`@hotwired/turbo-rails`, `@rails/actiontext`). The engine's importmap
|
|
57
|
+
pins stay for importmap hosts and for `test/dummy`.
|
|
58
|
+
|
|
59
|
+
**Charts are Chart.js, driven by one Stimulus controller.** The
|
|
60
|
+
`janela--chart` controller builds a chart on `connect` from data
|
|
61
|
+
attributes the view already renders (`labels`, `values`, the filter
|
|
62
|
+
key, the selected value) and destroys it on `disconnect`, which is
|
|
63
|
+
what makes a chart survive Turbo replacing its frame on every
|
|
64
|
+
cross-filter. `Chart.register(...registerables)` is called explicitly:
|
|
65
|
+
importing `{ Chart }` alone yields a Chart with no controllers and
|
|
66
|
+
`"bar" is not a registered controller` at runtime.
|
|
67
|
+
|
|
68
|
+
Chart.js reaches each host the way its other JavaScript does. Bundler
|
|
69
|
+
hosts declare it as a peer dependency and resolve it from their own
|
|
70
|
+
`node_modules`. Importmap hosts get a vendored, self-contained ESM
|
|
71
|
+
bundle at `app/assets/javascripts/janela/vendor/chart.js`, pinned as
|
|
72
|
+
`chart.js` unless the host already pins its own. The vendored file is
|
|
73
|
+
self-contained on purpose: Chart.js's own `dist/chart.js` imports a
|
|
74
|
+
`./chunks/` sibling that Propshaft digesting breaks, and the UMD
|
|
75
|
+
build sets a global instead of exporting.
|
|
76
|
+
|
|
77
|
+
**A chart click is a table click.** The chart controller's `onClick`
|
|
78
|
+
maps the hit element's index to its label and dispatches
|
|
79
|
+
`janela--chart:toggle` with `{ key, value }` in `detail`. The dashboard
|
|
80
|
+
controller's `toggle` reads `{ ...event.detail, ...event.params }`, so
|
|
81
|
+
a table button (params) and a chart (detail) arrive identically and the
|
|
82
|
+
dashboard cannot tell them apart.
|
|
83
|
+
|
|
84
|
+
**Renderer is a helper option and part of the frame identity.**
|
|
85
|
+
`janela_visual Order, :revenue, by: :status, as: :bar`. The renderer is
|
|
86
|
+
whitelisted (`table`, `bar`) and included in the frame id, so a table
|
|
87
|
+
and a chart of the same measure can share a page.
|
|
88
|
+
|
|
89
|
+
**Selection state is rendered by the server.** The filter on a
|
|
90
|
+
visual's own dimension is not applied to its query, but it is what the
|
|
91
|
+
user clicked, so the view marks it: `aria-pressed` on the table button,
|
|
92
|
+
a solid bar against faded siblings on the chart. Because the frame
|
|
93
|
+
reloads on every filter change, server-rendered state needs no
|
|
94
|
+
re-application in JavaScript.
|
|
95
|
+
|
|
96
|
+
**Securing endpoints is a documented host pattern, not a knob.**
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
Rails.application.config.to_prepare do
|
|
100
|
+
Janela::ApplicationController.prepend_before_action do
|
|
101
|
+
redirect_to main_app.new_session_path unless user_signed_in?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Prepended, so it runs before filters on the host's `ApplicationController`
|
|
107
|
+
that assume a user (tenant lookup was the one that raised). Through
|
|
108
|
+
`main_app`, because inside an isolated engine a bare host route helper
|
|
109
|
+
resolves against the engine's routes. Blazer documents the same
|
|
110
|
+
`main_app` requirement.
|
|
111
|
+
|
|
112
|
+
## Consequences
|
|
113
|
+
|
|
114
|
+
- The npm package's `files` glob excludes `vendor/`, so registry or
|
|
115
|
+
`github:` installs do not ship the vendored Chart.js. yarn 1's
|
|
116
|
+
`file:` protocol copies the whole directory regardless; harmless,
|
|
117
|
+
only relevant during local development against a sibling checkout.
|
|
118
|
+
- An importmap host with its own `chart.js` pin keeps it. The check is
|
|
119
|
+
`packages.key?("chart.js")` in the engine's `config/importmap.rb` and
|
|
120
|
+
depends on the host's importmap being evaluated first, which is the
|
|
121
|
+
order importmap-rails uses.
|
|
122
|
+
- Bar is the only chart type. Adding another is a new value in
|
|
123
|
+
`Visual::RENDERERS` and a `type` the controller passes through;
|
|
124
|
+
nothing about delivery or the click contract changes.
|
|
125
|
+
- Propshaft in development rescans assets only when the Rails
|
|
126
|
+
reloader fires, so a JavaScript-only change in an engine can appear
|
|
127
|
+
stale until a Ruby or view file changes or the server restarts.
|
|
128
|
+
Stale `public/assets` from an earlier `assets:precompile` shadows
|
|
129
|
+
`app/assets/builds` entirely. Both are host development gotchas
|
|
130
|
+
worth knowing when a controller "does not connect".
|
|
131
|
+
- A host whose sign-in route is not `new_session_path` adapts the one
|
|
132
|
+
line. If the pattern proves awkward across hosts, the Blazer-style
|
|
133
|
+
`Janela.before_action = :method_name` configuration is the fallback.
|
|
134
|
+
- CanCanCan hosts still get `model.all` silently (ADR 002). Unchanged
|
|
135
|
+
here and tracked as a pre-public issue.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# ADR Index
|
|
2
|
+
|
|
3
|
+
Topic-tagged map of all ADRs. Read this **before any non-trivial task**
|
|
4
|
+
to find which decisions are already made.
|
|
5
|
+
|
|
6
|
+
Every ADR carries `Triggers:` and `Topics:` near the top. Grep for
|
|
7
|
+
them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
8
|
+
|
|
9
|
+
## How to use
|
|
10
|
+
|
|
11
|
+
1. Identify the area of the task (scope, DSL, cross-filtering, etc).
|
|
12
|
+
2. Look up the topic below to see which ADRs are relevant.
|
|
13
|
+
3. Read the relevant ADRs **before** writing code, advising, or making
|
|
14
|
+
a decision in that area.
|
|
15
|
+
4. If the task spans topics, read all relevant ADRs.
|
|
16
|
+
5. If no ADR covers the area but the decision is significant, draft a
|
|
17
|
+
new one.
|
|
18
|
+
|
|
19
|
+
## Topics
|
|
20
|
+
|
|
21
|
+
| Topic | ADRs |
|
|
22
|
+
|-------|------|
|
|
23
|
+
| **Vision, scope, forkability** | 001 |
|
|
24
|
+
| **Open-source & host-decoupling** | 001 |
|
|
25
|
+
| **DSL & query layer** | 002 |
|
|
26
|
+
| **Dependencies** | 002, 003, 004 |
|
|
27
|
+
| **Authorisation** | 002, 003, 004 |
|
|
28
|
+
| **Cross-filtering & Hotwire** | 003, 004 |
|
|
29
|
+
| **JavaScript delivery & charts** | 004 |
|
|
30
|
+
| **Security** | 003 |
|
|
31
|
+
| **Testing** | 003 |
|
|
32
|
+
|
|
33
|
+
## Chronological
|
|
34
|
+
|
|
35
|
+
| ADR | Title | Date | Status |
|
|
36
|
+
|-----|-------|------|--------|
|
|
37
|
+
| 001 | Built to Be Forked | 2026-09-11 | Accepted |
|
|
38
|
+
| 002 | Measures and Dimensions over Ransack | 2026-09-13 | Accepted |
|
|
39
|
+
| 003 | Cross-filtering with Turbo Frames | 2026-09-13 | Accepted |
|
|
40
|
+
| 004 | Charts and JavaScript Delivery | 2026-09-15 | Accepted |
|
|
41
|
+
|
|
42
|
+
## Next number
|
|
43
|
+
|
|
44
|
+
Next ADR: 005
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
class Definition
|
|
3
|
+
attr_reader :model, :measures, :dimensions
|
|
4
|
+
|
|
5
|
+
def initialize(model)
|
|
6
|
+
@model = model
|
|
7
|
+
@measures = {}
|
|
8
|
+
@dimensions = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def measure(name, **aggregate)
|
|
12
|
+
measures[name] = Measure.build(name, **aggregate)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dimension(name, through: nil)
|
|
16
|
+
dimensions[name] = Dimension.new(name, model: model, through: through)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Filters are Ransack params, so a host can pass params[:q] straight
|
|
20
|
+
# through from a search_form_for slicer. Scope with on: to respect the
|
|
21
|
+
# host's authorisation, e.g. on: policy_scope(Order).
|
|
22
|
+
def query(measure_name, by: nil, where: {}, on: nil)
|
|
23
|
+
relation = filter(on || model.all, where)
|
|
24
|
+
|
|
25
|
+
if by
|
|
26
|
+
dimension = dimension!(by)
|
|
27
|
+
relation = relation.left_joins(dimension.through) if dimension.through
|
|
28
|
+
relation = relation.group(dimension.attribute)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
measure!(measure_name).apply(relation)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dimension!(name)
|
|
35
|
+
dimensions.fetch(name) { raise Error, "#{model} has no janela dimension #{name.inspect}" }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ransackable_attributes
|
|
39
|
+
dimensions.values.reject(&:through).map { |dimension| dimension.name.to_s }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ransackable_associations
|
|
43
|
+
dimensions.values.filter_map(&:through).map(&:to_s).uniq
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
def filter(relation, params)
|
|
48
|
+
return relation if params.empty?
|
|
49
|
+
|
|
50
|
+
search = relation.ransack(params)
|
|
51
|
+
reject_dropped_filters!(search, params)
|
|
52
|
+
search.result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Ransack silently discards conditions an allowlist does not permit,
|
|
56
|
+
# which would quietly return unfiltered numbers.
|
|
57
|
+
def reject_dropped_filters!(search, params)
|
|
58
|
+
applied = search.conditions.flat_map { |condition| condition.attributes.map(&:name) }
|
|
59
|
+
dropped = params.keys.reject { |key| applied.any? { |name| key.to_s.start_with?(name) } }
|
|
60
|
+
return if dropped.empty?
|
|
61
|
+
|
|
62
|
+
raise Error, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
|
|
63
|
+
"Declare a janela dimension, or add it to ransackable_attributes."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def measure!(name)
|
|
67
|
+
measures.fetch(name) { raise Error, "#{model} has no janela measure #{name.inspect}" }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|