janela 0.8.0 → 0.10.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +41 -2
  4. data/UPGRADING.md +75 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +29 -3
  6. data/app/assets/javascripts/janela/frame_controller.js +15 -0
  7. data/app/assets/stylesheets/janela.css +16 -8
  8. data/app/controllers/janela/application_controller.rb +7 -0
  9. data/app/controllers/janela/panes_controller.rb +22 -7
  10. data/app/controllers/janela/queries_controller.rb +2 -1
  11. data/app/helpers/janela/frames_helper.rb +26 -7
  12. data/app/models/janela/frame.rb +49 -0
  13. data/app/models/janela/pane.rb +67 -4
  14. data/app/models/janela/query.rb +10 -2
  15. data/app/views/janela/frames/_content.html.erb +26 -0
  16. data/app/views/janela/frames/_frame.html.erb +9 -1
  17. data/app/views/janela/frames/_pane.html.erb +2 -2
  18. data/app/views/janela/panes/_content_form.html.erb +33 -0
  19. data/app/views/janela/panes/_row.html.erb +1 -1
  20. data/app/views/janela/panes/edit.html.erb +5 -1
  21. data/app/views/janela/panes/new.html.erb +6 -1
  22. data/app/views/janela/queries/_query.html.erb +15 -11
  23. data/config/locales/en.yml +5 -0
  24. data/db/migrate/20260924000001_add_content_to_janela_panes.rb +16 -0
  25. data/db/migrate/20260924000002_add_key_to_janela_frames.rb +10 -0
  26. data/db/migrate/20260928000001_add_default_filter_to_janela_frames.rb +10 -0
  27. data/docs/composing.md +288 -0
  28. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +194 -0
  29. data/docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md +168 -0
  30. data/docs/decisions/040-a-host-can-fix-a-frames-filter.md +145 -0
  31. data/docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md +112 -0
  32. data/docs/decisions/042-a-charts-title-is-a-figcaption.md +174 -0
  33. data/docs/decisions/043-a-frames-default-filter-names-the-model-it-narrows.md +163 -0
  34. data/docs/decisions/INDEX.md +19 -12
  35. data/docs/multi-tenancy.md +22 -0
  36. data/docs/naming.md +7 -0
  37. data/docs/roadmap.md +129 -8
  38. data/docs/theming.md +18 -16
  39. data/lib/janela/definition.rb +8 -0
  40. data/lib/janela/version.rb +1 -1
  41. metadata +20 -18
@@ -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, :default, :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,15 @@ 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: {}, default: {}, 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
28
+ @default = default
27
29
  @snapshot = snapshot
28
30
 
29
31
  raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
@@ -97,6 +99,12 @@ module Janela
97
99
  def result(on: nil)
98
100
  return snapshot.stored_result(self) if frozen?
99
101
 
102
+ # Default, then fixed, then the reader's: a frame's own permanent
103
+ # filter narrows first, a host's per-record filter narrows again, and
104
+ # both apply even on this pane's own dimension, unlike the reader's
105
+ # selection, which this pane shows the alternatives to (ADR 040, 043).
106
+ on = definition.narrow(on || model.all, default) if default.present?
107
+ on = definition.narrow(on || model.all, fixed) if fixed.present?
100
108
  definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
101
109
  end
102
110
 
@@ -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 %>
@@ -1,6 +1,6 @@
1
1
  <li class="janela-list-row">
2
2
  <span class="janela-list-name"><%= pane.label %></span>
3
- <span class="janela-card-meta"><%= t("janela.panes.summary", renderer: pane.renderer, span: pane.span) %></span>
3
+ <span class="janela-card-meta"><%= pane.query? ? t("janela.panes.summary", renderer: pane.renderer, span: pane.span) : t("janela.panes.content_summary", kind: pane.partial? ? pane.partial.humanize : t("janela.panes.text"), span: pane.span) %></span>
4
4
  <span class="janela-actions">
5
5
  <%= button_to t("janela.actions.move_up"), move_up_frame_pane_path(frame, pane), method: :patch,
6
6
  class: "janela-button", disabled: pane == frame.panes.first %>
@@ -2,4 +2,8 @@
2
2
  <h1 class="janela-heading"><%= @pane.label %></h1>
3
3
  <p class="janela-crumb"><%= link_to @frame.name, edit_frame_path(@frame) %></p>
4
4
 
5
- <%= render "form", frame: @frame, pane: @pane, definition: @definition %>
5
+ <% if @pane.query? %>
6
+ <%= render "form", frame: @frame, pane: @pane, definition: @definition %>
7
+ <% else %>
8
+ <%= render "content_form", frame: @frame, pane: @pane %>
9
+ <% end %>
@@ -4,6 +4,8 @@
4
4
 
5
5
  <% if @definition %>
6
6
  <%= render "form", frame: @frame, pane: @pane, definition: @definition %>
7
+ <% elsif !@pane.query? %>
8
+ <%= render "content_form", frame: @frame, pane: @pane %>
7
9
  <% else %>
8
10
  <%# Step one of two. The engine's pages run no JavaScript, so one select
9
11
  cannot refill another: picking the model first is what lets step two
@@ -11,7 +13,10 @@
11
13
  <%= form_with url: new_frame_pane_path(@frame), method: :get, class: "janela-form" do |form| %>
12
14
  <div class="janela-field">
13
15
  <%= form.label :model, Janela::Pane.human_attribute_name(:model) %>
14
- <%= form.select :model, Janela.definitions.map { |definition| [ definition.model.model_name.human, definition.model.model_name.route_key ] } %>
16
+ <%= form.select :model, grouped_options_for_select(
17
+ t("janela.panes.numbers") => Janela.definitions.map { |definition| [ definition.model.model_name.human, definition.model.model_name.route_key ] },
18
+ t("janela.panes.words") => [ [ t("janela.panes.text"), "text" ] ] +
19
+ Janela::Pane.content_partials.map { |name| [ name.humanize, "partial:#{name}" ] }) %>
15
20
  </div>
16
21
 
17
22
  <div class="janela-actions">
@@ -8,17 +8,21 @@
8
8
  <% elsif result.empty? %>
9
9
  <p class="janela-pane janela-empty"><%= query.title %>: no data</p>
10
10
  <% elsif query.chart? %>
11
- <canvas class="janela-pane janela-chart"
12
- data-controller="janela--chart"
13
- data-action="janela--chart:toggle->janela--frame#toggle"
14
- data-janela--chart-type-value="<%= query.renderer %>"
15
- data-janela--chart-title-value="<%= query.title %>"
16
- data-janela--chart-selected-value="<%= query.selected_values.to_json %>"
17
- data-janela--chart-labels-value="<%= result.keys.to_json %>"
18
- data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
19
- data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
20
- data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
21
- role="img" aria-label="<%= query.title %>"></canvas>
11
+ <% title_id = "#{query.turbo_frame_id}-title" %>
12
+ <figure class="janela-pane">
13
+ <figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
14
+ <canvas class="janela-chart"
15
+ data-controller="janela--chart"
16
+ data-action="janela--chart:toggle->janela--frame#toggle"
17
+ data-janela--chart-type-value="<%= query.renderer %>"
18
+ data-janela--chart-title-value="<%= query.title %>"
19
+ data-janela--chart-selected-value="<%= query.selected_values.to_json %>"
20
+ data-janela--chart-labels-value="<%= result.keys.to_json %>"
21
+ data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
22
+ data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
23
+ data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
24
+ role="img" aria-labelledby="<%= title_id %>"></canvas>
25
+ </figure>
22
26
  <% else %>
23
27
  <table class="janela-pane">
24
28
  <caption><%= query.title %></caption>
@@ -58,6 +58,11 @@ en:
58
58
  no_dimension: "No breakdown, one number"
59
59
  no_limit: Every row
60
60
  summary: "%{renderer}, %{span} wide"
61
+ content_summary: "%{kind}, %{span} wide"
62
+ numbers: Numbers
63
+ words: Words
64
+ text: Text
65
+ link_hint: "A path on this site, such as /orders. Makes the heading a link."
61
66
  title_placeholder: Janela writes one if you leave this blank
62
67
  renderers:
63
68
  bar: Bar chart
@@ -0,0 +1,16 @@
1
+ class AddContentToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # A pane can hold words instead of a query (ADR 039). Every existing row
4
+ # is a query, which is the default.
5
+ add_column :janela_panes, :kind, :string, null: false, default: "query"
6
+ add_column :janela_panes, :heading, :string
7
+ add_column :janela_panes, :body, :text
8
+ add_column :janela_panes, :link, :string
9
+ add_column :janela_panes, :partial, :string
10
+
11
+ # A content pane has no query, so these are required by kind in the
12
+ # model rather than by the column.
13
+ change_column_null :janela_panes, :model, true
14
+ change_column_null :janela_panes, :measure, true
15
+ end
16
+ end
@@ -0,0 +1,10 @@
1
+ class AddKeyToJanelaFrames < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # The host's own name for a frame, so it finds one of an owner's several
4
+ # from code (ADR 041). Janela reads it only in Frame.for.
5
+ add_column :janela_frames, :key, :string
6
+ # One frame per owner and key. Frames without a key, every one an analyst
7
+ # makes, are not constrained: a null is never equal to another.
8
+ add_index :janela_frames, [ :owner_type, :owner_id, :key ], unique: true
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ class AddDefaultFilterToJanelaFrames < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # A frame's own permanent filter (ADR 043): only a pane over
4
+ # default_model takes it, so a frame that holds panes from more than one
5
+ # model is never narrowed by a condition that is not about it. Present
6
+ # or absent together; every existing frame has neither.
7
+ add_column :janela_frames, :default_model, :string
8
+ add_column :janela_frames, :default_where, :json
9
+ end
10
+ end
data/docs/composing.md ADDED
@@ -0,0 +1,288 @@
1
+ ---
2
+ Topics: composing, html, layout, host-integration, styling
3
+ ---
4
+
5
+ # Composing a Page Around Panes
6
+
7
+ A pane is a number, a table or a chart. Everything a dashboard has that
8
+ is not a query result, the heading, the icon beside it, a sentence of
9
+ explanation, a link to the full list, a progress bar, is ordinary HTML
10
+ that your application writes. This page is the reference for writing it
11
+ so that it sits properly beside Janela's own markup.
12
+
13
+ The short version: compose with the block form of `janela_frame`, put
14
+ your markup and the panes inside a `janela-frame` grid, and style both
15
+ with your own classes plus the hooks in [Theming Janela](theming).
16
+
17
+ ## Where your markup can go
18
+
19
+ `janela_frame` has two forms, and only one of them takes your HTML.
20
+
21
+ | Form | Your own HTML | Use it when |
22
+ | --- | --- | --- |
23
+ | `janela_frame do ... end` | anywhere inside the block | the page needs a heading, text, links or a layout of its own |
24
+ | `janela_frame @frame` | none | the dashboard is data an analyst edits without a deploy |
25
+
26
+ A stored frame renders panes and nothing else. That is deliberate (ADR
27
+ 012): a row names a measure the model declared, so an analyst arranges
28
+ what is shown and cannot put arbitrary content on the page. If a
29
+ dashboard needs a heading and an explanation, write them in the page
30
+ around the frame, or use the block form. Whether a stored frame should
31
+ hold content of its own, such as text or an image, has not been decided.
32
+
33
+ ```erb
34
+ <h2>Orders</h2>
35
+ <p>Everything placed this financial year.</p>
36
+ <%= janela_frame @frame %>
37
+ ```
38
+
39
+ Inside the block form, anything goes. The block's own `<div>` carries
40
+ the cross-filtering controller, so a button or a link inside it can
41
+ clear or re-point the panes, and markup that has nothing to do with
42
+ filtering is simply ignored by it.
43
+
44
+ ## The grid
45
+
46
+ The block form's wrapper is not a grid. Write the grid yourself with the
47
+ same classes a stored frame gets from its integers:
48
+
49
+ ```erb
50
+ <%= janela_frame do %>
51
+ <div class="janela-frame janela-cols-3 janela-gap-4">
52
+ <%= janela_pane Order, :revenue %>
53
+ <%= janela_pane Order, :orders %>
54
+ <%= janela_pane Order, :average_order %>
55
+ </div>
56
+ <% end %>
57
+ ```
58
+
59
+ Every direct child is a grid item, your own elements included, so a
60
+ heading can take a whole row and a text box can sit between two panes.
61
+ `janela_pane` takes no class of its own yet
62
+ ([#29](https://github.com/retail-tasker/janela/issues/29)), so wrap a
63
+ pane to span it:
64
+
65
+ ```erb
66
+ <div class="janela-span-2"><%= janela_pane Order, :revenue, by: :status, as: :bar %></div>
67
+ ```
68
+
69
+ Below `40rem` the grid is one column whatever you asked for.
70
+
71
+ Under the vitral theme every direct child of `janela-frame` is drawn as a
72
+ pane of glass, your heading and your notes included. That is usually
73
+ what you want, since the page reads as one window. When it is not, put
74
+ the element above the grid rather than in it.
75
+
76
+ ## A frame for one record
77
+
78
+ One frame is often wanted on every record's page, each copy narrowed to
79
+ its own record. Pass the condition as `where:` and every pane is
80
+ filtered by it before anything the reader selects:
81
+
82
+ ```erb
83
+ <%= janela_frame @frame, where: { queue_id_eq: @queue.id } %>
84
+ ```
85
+
86
+ The block form takes the same argument. Do not carry the record in the
87
+ page URL's `q[...]` instead: that is the reader's selection, and Clear
88
+ filters or Escape takes it off. `where:` is a view filter, not a
89
+ permission, so a record a reader must not see is kept out of reach by
90
+ your `policy_scope` (ADR 040). Do not also offer the fixed dimension as a
91
+ pane the reader can click: any value but the fixed one returns nothing.
92
+
93
+ A figure you compute yourself for the same page, such as the progress
94
+ bar below, takes the same condition in its own `where:`.
95
+
96
+ ## A frame's own permanent filter
97
+
98
+ Some conditions are not about which record's page this is, they are
99
+ true every time: "this queue never counts an archived row." `where:` is
100
+ code, written into a view, run again on every render. A frame's own
101
+ default filter is data instead, so an analyst changes it without a
102
+ deploy (ADR 043):
103
+
104
+ ```ruby
105
+ frame.update!(default_model: "orders", default_where: { status_in: %w[paid pending] })
106
+ ```
107
+
108
+ `default_model` says which model the condition is about, so a frame
109
+ holding panes from more than one model is never narrowed by a condition
110
+ that was never about the pane reading it. It is validated when you save
111
+ it, against that model's declared dimensions, rather than only
112
+ discovered wrong the next time a pane renders. Set both columns
113
+ together; either alone is a validation error.
114
+
115
+ ## Components
116
+
117
+ Each of these is plain HTML. The class names that start `janela-` are
118
+ the contract and will not change without an entry in `UPGRADING.md`.
119
+ The ones that do not are yours to name; the examples use a `card-`
120
+ prefix only so they read clearly.
121
+
122
+ ### A heading with an icon
123
+
124
+ A heading belongs to your page, not to a pane. Make it a full-width grid
125
+ item so it sits above the panes it introduces.
126
+
127
+ ```erb
128
+ <%= janela_frame do %>
129
+ <div class="janela-frame janela-cols-3 janela-gap-4">
130
+ <header class="janela-span-3 card-heading">
131
+ <svg aria-hidden="true" class="card-icon">...</svg>
132
+ <h2>Orders overview</h2>
133
+ </header>
134
+ <%= janela_pane Order, :revenue %>
135
+ ...
136
+ </div>
137
+ <% end %>
138
+ ```
139
+
140
+ Mark the icon `aria-hidden="true"`. The heading's text is its name, and
141
+ an icon read aloud as "image" adds nothing.
142
+
143
+ ### A text box
144
+
145
+ Explanation, a caveat, what the numbers exclude. A grid item like any
146
+ other, so it can span the row or sit beside a pane.
147
+
148
+ ```erb
149
+ <p class="janela-span-3 card-note">
150
+ Refunds are excluded. Figures are in the store's own currency.
151
+ </p>
152
+ ```
153
+
154
+ Write it in the page rather than in a pane's title. A title is the
155
+ pane's accessible name and should say what it measures, not carry a
156
+ paragraph.
157
+
158
+ ### A labelled number
159
+
160
+ A single value pane already renders its own label and number
161
+ (`janela-value-label`, `janela-value-number`). When your card already
162
+ says what the number is, hide the pane's label with
163
+ `janela-own-headings` so it is not on screen twice. It stays in the
164
+ accessibility tree.
165
+
166
+ ```erb
167
+ <div class="card janela-own-headings">
168
+ <p class="card-label">Revenue</p>
169
+ <%= janela_pane Order, :revenue %>
170
+ </div>
171
+ ```
172
+
173
+ ### Several numbers in one card
174
+
175
+ Two single value panes side by side read as one figure. Each is its own
176
+ query, so each cross-filters on its own.
177
+
178
+ ```erb
179
+ <div class="card janela-own-headings">
180
+ <p class="card-label">Revenue, from orders</p>
181
+ <div class="card-figure">
182
+ <%= janela_pane Order, :revenue %>
183
+ <span>from</span>
184
+ <%= janela_pane Order, :orders %>
185
+ </div>
186
+ </div>
187
+ ```
188
+
189
+ ```css
190
+ .card-figure { display: flex; align-items: baseline; gap: .5rem; }
191
+ ```
192
+
193
+ A single value pane is a block of its own, a `<p>` holding its label
194
+ and number, so the figure needs a `<div>` rather than a `<p>` around it,
195
+ and a line of CSS to put the numbers side by side. Without it they
196
+ stack.
197
+
198
+ A done out of total figure cannot be two panes yet. A measure has no
199
+ condition of its own, so there is no pane for the done half. Compute it
200
+ as the progress bar below does. A measure that is itself the ratio is
201
+ proposed in ADR 038
202
+ ([#27](https://github.com/retail-tasker/janela/issues/27)).
203
+
204
+ ### A progress bar
205
+
206
+ There is no progress renderer. A bar is your own markup, from a value
207
+ your controller computes through the same scope Janela uses.
208
+ `where:` takes the same Ransack conditions a pane's filters do, on the
209
+ dimensions the model declared:
210
+
211
+ ```ruby
212
+ scope = policy_scope(Order)
213
+ done = Order.janela.query(:orders, where: { status_eq: "paid" }, on: scope)
214
+ total = Order.janela.query(:orders, on: scope)
215
+ @percent = total.zero? ? 0 : (100.0 * done / total).round
216
+ ```
217
+
218
+ ```erb
219
+ <div class="janela-span-3 card-progress">
220
+ <p><span>Paid</span> <span><%= @percent %>%</span></p>
221
+ <progress max="100" value="<%= @percent %>"><%= @percent %>%</progress>
222
+ </div>
223
+ ```
224
+
225
+ Use `<progress>` rather than two nested `<div>`s: it has a role and a
226
+ value a screen reader can announce without any ARIA of your own. It is
227
+ computed once, when the page renders, so it does not move when a click
228
+ cross-filters the panes around it. Put it outside the frame, or say
229
+ that it is the unfiltered figure, so nobody reads it as filtered.
230
+
231
+ ### A link or an action
232
+
233
+ A link to the full list, or a button that clears the filters.
234
+
235
+ ```erb
236
+ <%= link_to "View all", orders_path, class: "card-link" %>
237
+ <button type="button" data-action="janela--frame#clear">Clear filters</button>
238
+ ```
239
+
240
+ A button only reaches `janela--frame` from inside the block. Outside
241
+ it, it has no frame to clear.
242
+
243
+ ## Putting it together
244
+
245
+ An overview card: a heading with an icon and a link, two numbers and a
246
+ note, then a progress bar under it.
247
+
248
+ ```erb
249
+ <%= janela_frame do %>
250
+ <div class="janela-frame janela-cols-3 janela-gap-4">
251
+ <header class="janela-span-3 card-heading">
252
+ <svg aria-hidden="true" class="card-icon">...</svg>
253
+ <h2>Orders overview</h2>
254
+ <%= link_to "View all", orders_path, class: "card-link" %>
255
+ </header>
256
+
257
+ <div class="janela-span-2 card janela-own-headings">
258
+ <p class="card-label">Revenue, from orders</p>
259
+ <div class="card-figure">
260
+ <%= janela_pane Order, :revenue %>
261
+ <span>from</span>
262
+ <%= janela_pane Order, :orders %>
263
+ </div>
264
+ </div>
265
+
266
+ <p class="card-note">Refunds are excluded.</p>
267
+ </div>
268
+ <% end %>
269
+
270
+ <div class="card-progress">
271
+ <p><span>Paid, across every order</span> <span><%= @percent %>%</span></p>
272
+ <progress max="100" value="<%= @percent %>"><%= @percent %>%</progress>
273
+ </div>
274
+ ```
275
+
276
+ The progress bar sits below the frame, and its label says it covers
277
+ every order, because it does not move when a click filters the panes
278
+ above it.
279
+
280
+ ## What this page is not
281
+
282
+ It is not a component library. Janela ships no `card` class and will
283
+ not: a heading, a paragraph and a link already exist, and your
284
+ application already has a way of drawing them. What Janela promises is
285
+ the grid and the pane hooks, so that your markup and its markup can sit
286
+ in one layout. If a component keeps needing something from a pane that
287
+ the hooks cannot give it, that is worth an issue rather than a
288
+ workaround.