unmagic-components 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 +53 -0
- data/LICENSE +21 -0
- data/README.md +337 -0
- data/app/assets/javascripts/unmagic/components/upsert.js +45 -0
- data/app/assets/stylesheets/unmagic/components.css +382 -0
- data/config/importmap.rb +3 -0
- data/lib/unmagic/components/action_view_helpers.rb +168 -0
- data/lib/unmagic/components/configuration.rb +36 -0
- data/lib/unmagic/components/detail_list/item.rb +31 -0
- data/lib/unmagic/components/detail_list.rb +65 -0
- data/lib/unmagic/components/engine.rb +39 -0
- data/lib/unmagic/components/form_builder.rb +205 -0
- data/lib/unmagic/components/renderers/empty_state.rb +22 -0
- data/lib/unmagic/components/renderers/pagination.rb +62 -0
- data/lib/unmagic/components/table/column.rb +46 -0
- data/lib/unmagic/components/table.rb +254 -0
- data/lib/unmagic/components/table_tag.rb +93 -0
- data/lib/unmagic/components/version.rb +7 -0
- data/lib/unmagic/components.rb +43 -0
- data/lib/unmagic-components.rb +3 -0
- metadata +137 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2e66d8f2c875d1c138f1a7921f52e7ec3e77a44b834eb6026025d40dec4ab9ab
|
|
4
|
+
data.tar.gz: c291912ef44869d6216760661a32b2046fa8820e718acffcd5a6d2cf7fbecc2a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fe94b2574228893a83a514cdd07f6608e2541710494e10775926e784c6dced8c83d9e3f6f47f0ad4a51845e26d1c2255fadbc3c120955b8980829b2467c645ca
|
|
7
|
+
data.tar.gz: fe2a0c8fe420bf563d69d7ce8fc0cde4001decf52d141c57e91407b6666ad40b15862f846315232511400fbadddbdde88e3aa512029d8f77cc1834635ac2f3b4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `table_for` passes any option it doesn't recognise to the `<table>` element, so a
|
|
13
|
+
view can set `class:`, `data:` or ARIA attributes without a wrapper. `id:` still
|
|
14
|
+
names the deferred turbo frame.
|
|
15
|
+
- `table.empty` and `table.no_results` take extra options and hand them to the
|
|
16
|
+
configured `empty_state` seam, for an app whose blank slate needs more than a
|
|
17
|
+
message (an icon, say).
|
|
18
|
+
- Live tables. `columns:` moves a table's column definitions into a partial, and
|
|
19
|
+
`row_for` renders the single `<tr>` those columns produce for one record, so a
|
|
20
|
+
Turbo Stream broadcast and the page render cannot drift. `rows_id:` puts an id
|
|
21
|
+
on the `<tbody>` for a stream to target, and `row_id:` overrides the row's own
|
|
22
|
+
id — needed over an STI collection, where `dom_id` names each subclass and the
|
|
23
|
+
rows would sort by type rather than by id.
|
|
24
|
+
- `FormBuilder`, the chrome around a form control: the `field` wrapper with its
|
|
25
|
+
label, required marker, hint and error line; `group` for a row of fields;
|
|
26
|
+
`errors_summary`; `check_box_field` and `check_box_collection`; `form_value_for`;
|
|
27
|
+
and a `submit` that conjugates its own label while submitting ("Save" ->
|
|
28
|
+
"Saving…") through `data-turbo-submits-with`. It deliberately does not style the
|
|
29
|
+
control itself — apps disagree about whether inputs carry a class or are styled
|
|
30
|
+
bare — so it emits structure and leaves appearance to the host.
|
|
31
|
+
- The `upsert` Turbo Stream action (`import "unmagic/components/upsert"`), which
|
|
32
|
+
merges `append` and `replace`: a row already on the page is replaced in place,
|
|
33
|
+
and a new one is inserted at the position its id sorts to. `order="desc"` on the
|
|
34
|
+
stream tag flips that for a newest-first list.
|
|
35
|
+
|
|
36
|
+
## [0.1.0] - 2026-09-10
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- `table_for`, a declarative index-table builder: columns with blocks or attribute
|
|
41
|
+
names, sortable headers with `aria-sort`, right/centre alignment, numeric columns,
|
|
42
|
+
`<colgroup>` width pinning, companion detail rows, and two flavours of empty state.
|
|
43
|
+
- Deferred tables — `table_for collection, defer: true` renders a skeleton inside a
|
|
44
|
+
Turbo Frame without touching the collection, then loads the real rows into it.
|
|
45
|
+
- `table_tag`, the underlying primitive, for static tables built from plain arrays.
|
|
46
|
+
- `detail_list`, a description-list builder with inline and stacked variants.
|
|
47
|
+
- Theming through `--unmagic-*` CSS custom properties, with every value falling back
|
|
48
|
+
to a Tailwind palette default so the components look right unconfigured.
|
|
49
|
+
- Configurable empty-state, pagination and Pagy seams so the gem depends on neither
|
|
50
|
+
Pagy nor any host helper.
|
|
51
|
+
|
|
52
|
+
[Unreleased]: https://github.com/unreasonable-magic/unmagic-components/compare/v0.1.0...HEAD
|
|
53
|
+
[0.1.0]: https://github.com/unreasonable-magic/unmagic-components/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keith Pitt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# Unmagic::Components
|
|
2
|
+
|
|
3
|
+
Declarative table and detail-list builders for server-rendered Rails views, in
|
|
4
|
+
the spirit of `form_for`: describe the columns, get the chrome.
|
|
5
|
+
|
|
6
|
+
```erb
|
|
7
|
+
<%= table_for @users do |table| %>
|
|
8
|
+
<% table.empty "No team members yet." %>
|
|
9
|
+
|
|
10
|
+
<% table.column "Name", width: "38%" do |user| %>
|
|
11
|
+
<%= link_to user.name, user %>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
<% table.column "Created", sort: :created_at, direction: :desc do |user| %>
|
|
15
|
+
<%= user.created_at.to_fs(:short) %>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<% table.column "Logins", :sign_in_count, numeric: true %>
|
|
19
|
+
<% end %>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- **Sortable headers** that toggle `?sort`/`?direction`, carry `aria-sort`, and
|
|
25
|
+
preserve the rest of the query string.
|
|
26
|
+
- **Deferred tables** — `defer: true` renders a skeleton inside a Turbo Frame
|
|
27
|
+
without touching the collection, then loads the real rows into it. The
|
|
28
|
+
skeleton keeps the loaded table's headers and column widths, so nothing shifts
|
|
29
|
+
when the data lands.
|
|
30
|
+
- **Two flavours of empty state**: a blank slate for an empty dataset, and a
|
|
31
|
+
separate one for a search that matched nothing, chosen automatically.
|
|
32
|
+
- **Companion detail rows** under any record, skipped per row when empty.
|
|
33
|
+
- **Detail lists** in inline and stacked layouts, with blanks rendered as an em
|
|
34
|
+
dash so call sites don't each need `.presence || "—"`.
|
|
35
|
+
- **No hard dependency** on a pagination library or on any helper of yours.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
gem "unmagic-components"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Add the stylesheet to your layout:
|
|
44
|
+
|
|
45
|
+
```erb
|
|
46
|
+
<%= stylesheet_link_tag "unmagic/components" %>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The engine mixes the helpers into ActionView automatically — no initializer
|
|
50
|
+
needed to get started.
|
|
51
|
+
|
|
52
|
+
## Theming
|
|
53
|
+
|
|
54
|
+
The stylesheet is plain CSS driven by custom properties, and every value falls
|
|
55
|
+
back to a Tailwind palette default, so the components look right unconfigured.
|
|
56
|
+
To match your own design, set the `--unmagic-*` knobs wherever your theme lives:
|
|
57
|
+
|
|
58
|
+
```css
|
|
59
|
+
:root {
|
|
60
|
+
--unmagic-surface: var(--surface);
|
|
61
|
+
--unmagic-surface-2: var(--surface-2);
|
|
62
|
+
--unmagic-hover: var(--hover);
|
|
63
|
+
--unmagic-border: var(--border);
|
|
64
|
+
--unmagic-border-strong: var(--border-strong);
|
|
65
|
+
--unmagic-text: var(--text);
|
|
66
|
+
--unmagic-text-2: var(--text-2);
|
|
67
|
+
--unmagic-text-3: var(--text-3);
|
|
68
|
+
--unmagic-skeleton: var(--surface-3);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Dark mode needs nothing extra. If your own tokens already flip, these flip with
|
|
73
|
+
them — the gem ships no dark variant and makes no assumption about how you
|
|
74
|
+
select a theme.
|
|
75
|
+
|
|
76
|
+
The CSS is deliberately **not** part of any Tailwind build. Tailwind only
|
|
77
|
+
generates classes it can see and it does not scan installed gems, so a component
|
|
78
|
+
library that emitted utilities from Ruby would render unstyled in your app
|
|
79
|
+
unless you pointed `@source` at the gem's install path.
|
|
80
|
+
|
|
81
|
+
If you do want Tailwind utilities from these tokens, map them with `@theme
|
|
82
|
+
inline` — not plain `@theme`. A non-inline theme variable makes the utility emit
|
|
83
|
+
`var(--color-unmagic-surface)`, which resolves *where that variable is defined*,
|
|
84
|
+
so an override scoped to a subtree (a themed preview pane, a `.dark` region)
|
|
85
|
+
would be invisible to it:
|
|
86
|
+
|
|
87
|
+
```css
|
|
88
|
+
@theme inline {
|
|
89
|
+
--color-unmagic-surface: var(--unmagic-surface);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Configuration
|
|
94
|
+
|
|
95
|
+
Three seams, each with a working default. Point them at your own versions if you
|
|
96
|
+
already own these concerns:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# config/initializers/unmagic_components.rb
|
|
100
|
+
Unmagic::Components.configure do |config|
|
|
101
|
+
# The table's blank slate. Called with (view, content, **options), where the
|
|
102
|
+
# options are whatever `table.empty` was given beyond its text.
|
|
103
|
+
config.empty_state = ->(view, content, **options) { view.empty_state(content) }
|
|
104
|
+
|
|
105
|
+
# The table's pager. Called with (view, pagy:, turbo_frame:).
|
|
106
|
+
config.pagination = ->(view, pagy:, turbo_frame:) {
|
|
107
|
+
view.render "shared/pagination", pagy: pagy, turbo_frame: turbo_frame
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Resolves the pager object a table pages with. Called with (view, collection);
|
|
111
|
+
# return nil to suppress the pager. Nothing here is Pagy-specific — the
|
|
112
|
+
# renderer only needs something answering previous/next/page_url, so
|
|
113
|
+
# geared_pagination or your own object works just as well.
|
|
114
|
+
config.pagy_for = ->(view, collection) { view.table_pagy(collection) }
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Usage
|
|
119
|
+
|
|
120
|
+
### `table_for(collection, **options, &block)`
|
|
121
|
+
|
|
122
|
+
| Option | Default | Meaning |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `defer:` | `false` | Render a skeleton in a Turbo Frame first, then load the rows |
|
|
125
|
+
| `id:` | `"#{controller_name}_table"` | The frame id, when deferring |
|
|
126
|
+
| `paginate:` | `true` | `false` suppresses the pager; a pagy object is used directly |
|
|
127
|
+
| `headers:` | `true` | `false` omits the `<thead>` |
|
|
128
|
+
| `sorted_by:` | `params[:sort]` | The currently applied sort key |
|
|
129
|
+
| `sort_direction:` | `params[:direction]` | `:asc` or `:desc` |
|
|
130
|
+
| `sort_url:` | — | `->(key, direction) { url }`, when sorting rides on other params |
|
|
131
|
+
| `row_class:` | — | `->(record) { "…" }` for extra `<tr>` classes |
|
|
132
|
+
|
|
133
|
+
Any other option rides on the `<table>` itself — `class:`, `data:`, `aria-*` — so a
|
|
134
|
+
view can space or annotate the table without wrapping it in a div. `id:` is the
|
|
135
|
+
exception: it names the deferred turbo frame, not the table.
|
|
136
|
+
|
|
137
|
+
On the yielded builder:
|
|
138
|
+
|
|
139
|
+
- `column(title = nil, attribute = nil, **options, &block)` — content comes from
|
|
140
|
+
the block, else `record.public_send(attribute)`. Options: `sort:`,
|
|
141
|
+
`direction:`, `align:` (`:right`/`:center`), `numeric:` (right-aligns and uses
|
|
142
|
+
tabular figures), `width:`, `class:`.
|
|
143
|
+
- `details(&block)` — a full-width companion row per record; capturing nothing
|
|
144
|
+
skips it.
|
|
145
|
+
- `empty(text = nil, **options, &block)` — the blank slate for an empty dataset.
|
|
146
|
+
- `no_results(text = nil, **options, &block)` — shown instead when the collection
|
|
147
|
+
responds to `filtered?` with true.
|
|
148
|
+
|
|
149
|
+
Extra options on `empty`/`no_results` are handed to the configured `empty_state`
|
|
150
|
+
seam, so an app whose blank slate takes more than a message can ask for it per
|
|
151
|
+
table:
|
|
152
|
+
|
|
153
|
+
```erb
|
|
154
|
+
<% table.empty "No labels yet.", icon: "tag" %>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
config.empty_state = ->(view, content, **options) do
|
|
159
|
+
view.render "shared/empty", icon: options.fetch(:icon, "table"), message: content
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`width:` takes a CSS length (`"40%"`, `"170px"`), which rides on a `<col>` as a
|
|
164
|
+
style, or any other string, which is used as a class name so a Tailwind app can
|
|
165
|
+
pass `"w-[40%]"`. Any width switches the table to a fixed layout. Give every
|
|
166
|
+
column of a deferred table a width, so the skeleton and the rows that replace it
|
|
167
|
+
lay out identically.
|
|
168
|
+
|
|
169
|
+
### `detail_list(variant: :inline, **options, &block)`
|
|
170
|
+
|
|
171
|
+
```erb
|
|
172
|
+
<%= detail_list variant: :stacked do |list| %>
|
|
173
|
+
<% list.item "Client ID", @application.uid, class: "font-mono" %>
|
|
174
|
+
<% list.item "Redirect URIs", span: :full do %>
|
|
175
|
+
<% @application.redirect_uris.each do |uri| %>
|
|
176
|
+
<div><%= uri %></div>
|
|
177
|
+
<% end %>
|
|
178
|
+
<% end %>
|
|
179
|
+
<% list.item "Registered", @application.created_at %>
|
|
180
|
+
<% end %>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`:inline` lays labels beside values in a two-column grid; `:stacked` puts small
|
|
184
|
+
caps labels above values, in two columns once there is room. An item's `class:`
|
|
185
|
+
lands on its `<dd>`, and `span: :full` stretches a stacked item across both
|
|
186
|
+
columns.
|
|
187
|
+
|
|
188
|
+
### `table_tag(headers, rows, **options)`
|
|
189
|
+
|
|
190
|
+
The primitive the table is built on, for a static table that wants the same look
|
|
191
|
+
without the record/sort/pagination machinery:
|
|
192
|
+
|
|
193
|
+
```erb
|
|
194
|
+
<%= table_tag [ "Name", "Score" ], [ [ "Ann", 42 ], [ "Bob", 7 ] ], aligns: [ nil, :right ] %>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A cell is a value, or a `{ content:, **attrs }` hash setting attributes on its
|
|
198
|
+
`th`/`td`. A row is an array of cells, or a `{ cells:, **attrs }` hash setting
|
|
199
|
+
attributes on its `<tr>`. `aligns:` and `widths:` are per-column, and `caption:`
|
|
200
|
+
adds a screen-reader-only caption.
|
|
201
|
+
|
|
202
|
+
## Forms
|
|
203
|
+
|
|
204
|
+
`FormBuilder` is the chrome around a control — the wrapper, the label and its
|
|
205
|
+
required marker, the hint, and the error line — the part every app writes the same
|
|
206
|
+
way and then repeats in every view.
|
|
207
|
+
|
|
208
|
+
```erb
|
|
209
|
+
<%= form_with model: @label, builder: Unmagic::Components::FormBuilder do |form| %>
|
|
210
|
+
<%= form.errors_summary %>
|
|
211
|
+
<%= form.field :name, "Name", required: true, hint: "A key is derived from it." %>
|
|
212
|
+
<%= form.field :colour, "Colour" do %>
|
|
213
|
+
<%= form.select :colour, Label::COLOURS %>
|
|
214
|
+
<% end %>
|
|
215
|
+
<%= form.submit "Add label" %>
|
|
216
|
+
<% end %>
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Set it as the default with `config.action_view.default_form_builder`, or pass
|
|
220
|
+
`builder:` per form.
|
|
221
|
+
|
|
222
|
+
- `field(method, label, required:, hint:, as:, &block)` — the control comes from
|
|
223
|
+
`as:` (any builder method), or from the block when you pass one. An invalid field
|
|
224
|
+
gets `aria-invalid="true"` and its errors underneath, read as a sentence.
|
|
225
|
+
- `group(inline: false)` — lay the contained fields out in a row.
|
|
226
|
+
- `errors_summary` — the record's whole-object (`:base`) errors.
|
|
227
|
+
- `check_box_field`, `check_box_collection` — a checkbox with its label beside it.
|
|
228
|
+
- `submit` — conjugates its label for the length of the submit ("Save" → "Saving…",
|
|
229
|
+
"Add label" → "Adding label…") via `data-turbo-submits-with`. Pass
|
|
230
|
+
`submitting: false` to leave it alone, or a string to choose it. A block supplies
|
|
231
|
+
your own button content, e.g. an icon.
|
|
232
|
+
- `form_value_for` — the value to show, whether the object is a model or something
|
|
233
|
+
hash-ish, preferring what the user actually typed.
|
|
234
|
+
|
|
235
|
+
**What the control looks like is not decided here.** Apps style inputs in
|
|
236
|
+
incompatible ways — a class on every input, or a bare-element rule — and a
|
|
237
|
+
component library that picked one would be wrong in the other. So the builder emits
|
|
238
|
+
structure and the gem's CSS styles only the label, hint and error. Point your own
|
|
239
|
+
input rules at `.UnmagicField` to give its controls your look:
|
|
240
|
+
|
|
241
|
+
```css
|
|
242
|
+
.field,
|
|
243
|
+
.UnmagicField {
|
|
244
|
+
/* your existing input rules */
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The submit button's classes come from a seam, so it wears your own button:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
config.submit_class = ->(_view, variant) { variant == :primary ? "btn btn-primary" : "btn" }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Live tables
|
|
255
|
+
|
|
256
|
+
A table whose rows a Turbo Stream keeps up to date needs the column definitions in
|
|
257
|
+
a place both the page render and a single broadcast row can reach, so they move
|
|
258
|
+
into a partial that takes a `table` local and does nothing but declare them:
|
|
259
|
+
|
|
260
|
+
```erb
|
|
261
|
+
<%# tasks/_columns.html.erb %>
|
|
262
|
+
<% table.column "Task", width: "30%" do |task| %>
|
|
263
|
+
<%= link_to task.kind, task %>
|
|
264
|
+
<% end %>
|
|
265
|
+
<% table.column "Status" do |task| %>
|
|
266
|
+
<span class="badge"><%= task.status %></span>
|
|
267
|
+
<% end %>
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The page renders the table with them, names the `<tbody>` so a stream can target
|
|
271
|
+
it, and gives the rows one id prefix:
|
|
272
|
+
|
|
273
|
+
```erb
|
|
274
|
+
<%= turbo_stream_from "tasks" %>
|
|
275
|
+
|
|
276
|
+
<%= table_for @tasks, columns: "tasks/columns", rows_id: "task_rows",
|
|
277
|
+
row_id: ->(task) { "task_#{task.id}" } do |table| %>
|
|
278
|
+
<% table.empty "Nothing has run yet." %>
|
|
279
|
+
<% end %>
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
A broadcast renders one row from the same partial:
|
|
283
|
+
|
|
284
|
+
```erb
|
|
285
|
+
<%# tasks/_row.html.erb %>
|
|
286
|
+
<%= row_for task, columns: "tasks/columns", row_id: ->(task) { "task_#{task.id}" } %>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
class Task < ApplicationRecord
|
|
291
|
+
after_create_commit :broadcast_row
|
|
292
|
+
after_update_commit :broadcast_row
|
|
293
|
+
|
|
294
|
+
private
|
|
295
|
+
def broadcast_row
|
|
296
|
+
broadcast_action_to "tasks", action: :upsert, target: "task_rows",
|
|
297
|
+
attributes: { order: "desc" }, partial: "tasks/row", locals: { task: self }
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`upsert` is a Turbo Stream action this gem ships. Import it once:
|
|
303
|
+
|
|
304
|
+
```js
|
|
305
|
+
// app/javascript/application.js
|
|
306
|
+
import "unmagic/components/upsert"
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
It merges `append` and `replace`. If an element with the incoming id is already in
|
|
310
|
+
the document it is replaced in place, so a record that is both server-rendered and
|
|
311
|
+
broadcast never duplicates. Otherwise it is inserted at the position its id sorts
|
|
312
|
+
to, so out-of-order delivery still lands in order — which assumes time-ordered ids
|
|
313
|
+
(UUIDv7, ULID). `order="desc"` flips the comparison for a newest-first list.
|
|
314
|
+
|
|
315
|
+
**`row_id:` matters over an STI collection.** `dom_id` names the record's own
|
|
316
|
+
class, so subclasses get different prefixes and the rows sort by type rather than
|
|
317
|
+
by id. Give every row one prefix and the time-ordered id decides.
|
|
318
|
+
|
|
319
|
+
The companion `details` row is not broadcast: a stream action carries one element,
|
|
320
|
+
and the pair is a page-render concern.
|
|
321
|
+
|
|
322
|
+
## Development
|
|
323
|
+
|
|
324
|
+
```sh
|
|
325
|
+
bundle install
|
|
326
|
+
bundle exec rspec
|
|
327
|
+
bundle exec rubocop
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Contributing
|
|
331
|
+
|
|
332
|
+
Bug reports and pull requests are welcome at
|
|
333
|
+
https://github.com/unreasonable-magic/unmagic-components.
|
|
334
|
+
|
|
335
|
+
## License
|
|
336
|
+
|
|
337
|
+
Available as open source under the terms of the [MIT License](LICENSE).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// A Turbo Stream action that merges `append` and `replace`:
|
|
2
|
+
//
|
|
3
|
+
// <turbo-stream action="upsert" target="CONTAINER_ID">
|
|
4
|
+
// <template><tr id="ELEMENT_ID">…</tr></template>
|
|
5
|
+
// </turbo-stream>
|
|
6
|
+
//
|
|
7
|
+
// The template holds one element with a stable id. If an element with that id is already in the
|
|
8
|
+
// document — server-rendered on page load, streamed in earlier, or an optimistic placeholder the
|
|
9
|
+
// server is now confirming — it is replaced in place, so a record that is both on the page and
|
|
10
|
+
// broadcast never duplicates. Otherwise it is inserted into the target container at the position
|
|
11
|
+
// its id sorts to, so out-of-order delivery still lands in id order. That ordering assumes
|
|
12
|
+
// time-ordered ids (UUIDv7, ULID); with random ids the position is stable but arbitrary.
|
|
13
|
+
//
|
|
14
|
+
// A list showing newest first says so, and the comparison flips:
|
|
15
|
+
//
|
|
16
|
+
// <turbo-stream action="upsert" target="task_rows" order="desc">
|
|
17
|
+
//
|
|
18
|
+
// broadcast_action_to "tasks", action: :upsert, target: "task_rows",
|
|
19
|
+
// attributes: { order: "desc" }, partial: "tasks/row", locals: { task: self }
|
|
20
|
+
import { Turbo } from "@hotwired/turbo-rails"
|
|
21
|
+
|
|
22
|
+
Turbo.StreamActions.upsert = function () {
|
|
23
|
+
const incoming = this.templateContent.firstElementChild
|
|
24
|
+
if (!incoming) return
|
|
25
|
+
|
|
26
|
+
const existing = incoming.id ? document.getElementById(incoming.id) : null
|
|
27
|
+
if (existing) {
|
|
28
|
+
existing.replaceWith(incoming)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const container = this.targetElements[0]
|
|
33
|
+
if (!container) return
|
|
34
|
+
|
|
35
|
+
const descending = this.getAttribute("order") === "desc"
|
|
36
|
+
|
|
37
|
+
// Without an id there is nothing to sort on, so it goes last.
|
|
38
|
+
const successor = incoming.id
|
|
39
|
+
? Array.from(container.children).find(
|
|
40
|
+
(child) => child.id && (descending ? child.id < incoming.id : child.id > incoming.id),
|
|
41
|
+
)
|
|
42
|
+
: null
|
|
43
|
+
|
|
44
|
+
container.insertBefore(incoming, successor ?? null)
|
|
45
|
+
}
|