tramway 4.0.1.4 → 4.0.2
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 +4 -4
- data/README.md +34 -0
- data/app/assets/javascripts/tramway/tramway.js +83 -1
- data/app/controllers/tramway/entities_controller.rb +35 -6
- data/app/views/tramway/entities/_filters.html.haml +15 -0
- data/app/views/tramway/entities/_list.html.haml +22 -8
- data/config/locales/en.yml +2 -0
- data/config/tailwind.config.js +3 -0
- data/lib/generators/tramway/install/install_generator.rb +5 -3
- data/lib/tramway/configs/entities/page.rb +1 -0
- data/lib/tramway/errors.rb +20 -0
- data/lib/tramway/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1254075cd4e025f5a4f46d4951fc391bd8c90a4c1a486b3bbe471b0d79cfebb
|
|
4
|
+
data.tar.gz: d37cb6b0408b55f635f75b8cc8f0fb90d5d5efa454a1db5c4303fdf800e45726
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 29d854a96abfcadda07911e326932a2347c509a0465dc5e6d4c19533dd7b5299f05aa011d3d928490dc6a17969143dc0490f81206618ecd13bd37040bc5306a3
|
|
7
|
+
data.tar.gz: d176134cef60ad20d9c2617067d0e4c498a966fef4119ef182dd2d13b32ae7919cce4ab2a3d18d076b0a20a7a6a08bda88b667190c072bfd41a2021ffa84c1af
|
data/README.md
CHANGED
|
@@ -261,6 +261,40 @@ In this example, Tramway calls `Campaign.includes(:user, :comments)` before appl
|
|
|
261
261
|
`campaign.user` or `campaign.comments` in the decorator's `index_attributes` does not issue an extra query per row.
|
|
262
262
|
Omitting `includes` produces identical behavior to before this option existed.
|
|
263
263
|
|
|
264
|
+
**filters**
|
|
265
|
+
|
|
266
|
+
Filters are disabled by default on index pages. Enable them by listing the scope names you want selectable under
|
|
267
|
+
`filters` on the `:index` page entry:
|
|
268
|
+
|
|
269
|
+
*config/initializers/tramway.rb*
|
|
270
|
+
```ruby
|
|
271
|
+
Tramway.configure do |config|
|
|
272
|
+
config.entities = [
|
|
273
|
+
{
|
|
274
|
+
name: :campaign,
|
|
275
|
+
namespace: :admin,
|
|
276
|
+
pages: [
|
|
277
|
+
{
|
|
278
|
+
action: :index,
|
|
279
|
+
filters: [:active, :archived]
|
|
280
|
+
}
|
|
281
|
+
]
|
|
282
|
+
}
|
|
283
|
+
]
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
When filters are configured, Tramway adds a "Filters" toggle to the same row as the search box and pagination, above
|
|
288
|
+
the index listing. Clicking it reveals a list of small buttons, one per configured filter, each showing how many
|
|
289
|
+
records currently match it (e.g. "ACTIVE · 34"). Clicking a button navigates to the index page with that filter
|
|
290
|
+
applied, keeping any existing search query. The currently applied filter is highlighted, and the toggle remembers
|
|
291
|
+
whether the list was last left open or closed.
|
|
292
|
+
|
|
293
|
+
Visiting the index page with `?filter=active` calls `Campaign.active` (the scope must already exist on the model) in
|
|
294
|
+
addition to any configured `scope`. If `filter` does not match one of the configured filters, Tramway raises
|
|
295
|
+
`Tramway::Errors::InvalidFilterError` explaining which filters are available. Omitting `filters` (the default) makes
|
|
296
|
+
Tramway ignore `params[:filter]` entirely.
|
|
297
|
+
|
|
264
298
|
**show page**
|
|
265
299
|
|
|
266
300
|
To render a show page for an entity, declare a `:show` action inside the `pages` array in
|
|
@@ -482,4 +482,86 @@ class Navbar extends Controller {
|
|
|
482
482
|
}
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
-
|
|
485
|
+
class Collapsible extends Controller {
|
|
486
|
+
static targets = ["panel", "icon", "toggle"]
|
|
487
|
+
|
|
488
|
+
connect() {
|
|
489
|
+
this.expanded = true
|
|
490
|
+
|
|
491
|
+
const stored = this.readStored()
|
|
492
|
+
const expanded = stored === null ? true : stored === 'true'
|
|
493
|
+
|
|
494
|
+
this.setExpanded(expanded, { animate: false })
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
toggle(event) {
|
|
498
|
+
event.preventDefault()
|
|
499
|
+
|
|
500
|
+
this.setExpanded(!this.expanded, { persist: true })
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
setExpanded(expanded, { persist = false, animate = true } = {}) {
|
|
504
|
+
this.expanded = expanded
|
|
505
|
+
|
|
506
|
+
if (animate) {
|
|
507
|
+
this.animateTo(expanded)
|
|
508
|
+
} else {
|
|
509
|
+
this.panelTarget.style.height = expanded ? 'auto' : '0px'
|
|
510
|
+
this.panelTarget.style.opacity = expanded ? '1' : '0'
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const expandedCarrier = this.hasToggleTarget ? this.toggleTarget : this.element
|
|
514
|
+
expandedCarrier.setAttribute('aria-expanded', expanded ? 'true' : 'false')
|
|
515
|
+
|
|
516
|
+
if (this.hasIconTarget) {
|
|
517
|
+
this.iconTarget.classList.toggle('rotate-180', !expanded)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (persist) {
|
|
521
|
+
this.writeStored(expanded)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Animates between the panel's current rendered height and its target height, rather than
|
|
526
|
+
// to/from `auto` directly (which cannot be transitioned). `height` is one of the few
|
|
527
|
+
// properties animate.md tolerates outside transform/opacity, since an accordion has no
|
|
528
|
+
// transform equivalent.
|
|
529
|
+
animateTo(expanded) {
|
|
530
|
+
const panel = this.panelTarget
|
|
531
|
+
const targetHeight = expanded ? panel.scrollHeight : 0
|
|
532
|
+
|
|
533
|
+
panel.style.height = `${panel.getBoundingClientRect().height}px`
|
|
534
|
+
panel.getBoundingClientRect() // force reflow so the browser registers the start value
|
|
535
|
+
|
|
536
|
+
requestAnimationFrame(() => {
|
|
537
|
+
panel.style.height = `${targetHeight}px`
|
|
538
|
+
panel.style.opacity = expanded ? '1' : '0'
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
panel.addEventListener('transitionend', () => {
|
|
542
|
+
if (this.expanded) panel.style.height = 'auto'
|
|
543
|
+
}, { once: true })
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
readStored() {
|
|
547
|
+
try {
|
|
548
|
+
return window.localStorage.getItem(this.storageKey)
|
|
549
|
+
} catch {
|
|
550
|
+
return null
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
writeStored(expanded) {
|
|
555
|
+
try {
|
|
556
|
+
window.localStorage.setItem(this.storageKey, expanded ? 'true' : 'false')
|
|
557
|
+
} catch {
|
|
558
|
+
/* localStorage unavailable (e.g. private mode) — state simply won't persist */
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
get storageKey() {
|
|
563
|
+
return `tramway-collapsible-${this.element.dataset.key || 'default'}`
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export { TramwaySelect, UiCheckbox, Tooltip, Navbar, Collapsible }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'tramway/warnings'
|
|
4
|
+
require 'tramway/errors'
|
|
4
5
|
|
|
5
6
|
module Tramway
|
|
6
7
|
# Main controller for entities pages
|
|
@@ -13,18 +14,15 @@ module Tramway
|
|
|
13
14
|
include Rails.application.routes.url_helpers
|
|
14
15
|
|
|
15
16
|
def index
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
else
|
|
19
|
-
model_class.order(id: :desc)
|
|
20
|
-
end => entities
|
|
21
|
-
|
|
17
|
+
entities = base_entities
|
|
18
|
+
entities = apply_filter(entities)
|
|
22
19
|
entities = preload(entities)
|
|
23
20
|
entities = search(entities)
|
|
24
21
|
entities = entities.page(params[:page])
|
|
25
22
|
@entities = entities
|
|
26
23
|
|
|
27
24
|
@namespace = entity.namespace
|
|
25
|
+
@filter_counts = filter_counts if filters.present?
|
|
28
26
|
end
|
|
29
27
|
|
|
30
28
|
def show
|
|
@@ -91,6 +89,37 @@ module Tramway
|
|
|
91
89
|
entity.page(:index).scope
|
|
92
90
|
end
|
|
93
91
|
|
|
92
|
+
def base_entities
|
|
93
|
+
if index_scope.present?
|
|
94
|
+
model_class.public_send(index_scope)
|
|
95
|
+
else
|
|
96
|
+
model_class.order(id: :desc)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def filters
|
|
101
|
+
entity.page(:index).filters
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def apply_filter(entities)
|
|
105
|
+
return entities if params[:filter].blank? || filters.blank?
|
|
106
|
+
|
|
107
|
+
filter = params[:filter].to_s
|
|
108
|
+
|
|
109
|
+
unless filters.map(&:to_s).include?(filter)
|
|
110
|
+
raise Tramway::Errors::InvalidFilterError.new(filter:, available_filters: filters, model_class:)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
entities.public_send(filter)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def filter_counts
|
|
117
|
+
# Count by :id explicitly: a bare `.count` reuses any custom `select` values from the
|
|
118
|
+
# index page's `scope` (e.g. a raw-SQL aliased subquery column), which breaks the
|
|
119
|
+
# generated SQL. Counting a real column side-steps that entirely.
|
|
120
|
+
filters.index_with { |filter| base_entities.public_send(filter).count(:id) }
|
|
121
|
+
end
|
|
122
|
+
|
|
94
123
|
def preload(entities)
|
|
95
124
|
includes = entity.page(:index).includes
|
|
96
125
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
- current_filter = params[:filter]
|
|
2
|
+
|
|
3
|
+
.overflow-hidden.duration-200.ease-out-strong.motion-reduce:duration-0{ class: 'transition-[height,opacity]', data: { 'tramway--collapsible-target': 'panel' } }
|
|
4
|
+
.flex.flex-wrap.gap-2.mt-2
|
|
5
|
+
- @filter_counts.each do |filter_name, count|
|
|
6
|
+
- filter_params = request.query_parameters.except('page').merge(filter: filter_name)
|
|
7
|
+
- button_type = filter_name.to_s == current_filter ? :primary : :inverse
|
|
8
|
+
= tramway_button text: "#{filter_name.to_s.upcase} · #{count}",
|
|
9
|
+
path: public_send(custom_path_method, filter_params), type: button_type, size: :small
|
|
10
|
+
|
|
11
|
+
- if current_filter.present?
|
|
12
|
+
- clear_params = request.query_parameters.except('page', 'filter')
|
|
13
|
+
= tramway_button tag: :a, path: public_send(custom_path_method, clear_params), type: :inverse,
|
|
14
|
+
size: :small, tooltip: { text: t('tramway.actions.clear_filter'), event: :hover } do
|
|
15
|
+
%i.fa.fa-times{ 'aria-hidden' => 'true' }
|
|
@@ -20,14 +20,28 @@
|
|
|
20
20
|
.mt-4
|
|
21
21
|
= decorator.constantize.index_header_content.call(@entities)
|
|
22
22
|
|
|
23
|
-
.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
- filters_key = "#{@entity.namespace}-#{@entity.name}-index-filters" if @filter_counts.present?
|
|
24
|
+
|
|
25
|
+
%div{ data: (@filter_counts.present? ? { controller: 'tramway--collapsible', key: filters_key } : {}) }
|
|
26
|
+
.flex.flex-row.items-center.mt-2.gap-4{ class: (@filter_counts.present? ? 'justify-between' : 'justify-end') }
|
|
27
|
+
- if @filter_counts.present?
|
|
28
|
+
= tramway_button tag: :button, type: :inverse,
|
|
29
|
+
data: { action: 'tramway--collapsible#toggle', 'tramway--collapsible-target': 'toggle' } do
|
|
30
|
+
%i.fa.fa-filter.mr-1{ 'aria-hidden' => 'true' }
|
|
31
|
+
= t('tramway.actions.filters')
|
|
32
|
+
%i.fa.fa-chevron-down.ml-1.transition-transform.duration-200.ease-out-strong.motion-reduce:duration-0{ 'aria-hidden' => 'true', data: { 'tramway--collapsible-target': 'icon' } }
|
|
33
|
+
|
|
34
|
+
.flex.flex-row.items-center.gap-4
|
|
35
|
+
- if @entity.page(:index).search
|
|
36
|
+
= form_with url: public_send(custom_path_method), method: :get, local: true, builder: Tramway::Form::Builder, horizontal: true do |f|
|
|
37
|
+
= f.text_field :query, label: false, value: params[:query], placeholder: t('tramway.actions.search')
|
|
38
|
+
= f.submit nil, 'aria-label' => t('tramway.actions.search') do
|
|
39
|
+
%i.fa.fa-search{ 'aria-hidden' => 'true' }
|
|
40
|
+
|
|
41
|
+
= paginate @entities, custom_path_method:
|
|
42
|
+
|
|
43
|
+
- if @filter_counts.present?
|
|
44
|
+
= render 'tramway/entities/filters', custom_path_method:
|
|
31
45
|
|
|
32
46
|
- if index_attributes.empty?
|
|
33
47
|
%p.text-center.mt-10
|
data/config/locales/en.yml
CHANGED
data/config/tailwind.config.js
CHANGED
|
@@ -61,6 +61,7 @@ module.exports = {
|
|
|
61
61
|
'ease-in-out',
|
|
62
62
|
'md:transition-[width]',
|
|
63
63
|
'transition-[padding-left]',
|
|
64
|
+
'transition-[height,opacity]',
|
|
64
65
|
'ease-in-out-strong',
|
|
65
66
|
'md:ease-in-out-strong',
|
|
66
67
|
'ease-out-strong',
|
|
@@ -468,6 +469,8 @@ module.exports = {
|
|
|
468
469
|
'whitespace-nowrap',
|
|
469
470
|
'h-fit',
|
|
470
471
|
'gap-1',
|
|
472
|
+
'mr-1',
|
|
473
|
+
'ml-1',
|
|
471
474
|
'h-10',
|
|
472
475
|
'px-2.5',
|
|
473
476
|
'py-0.5',
|
|
@@ -115,7 +115,7 @@ module Tramway
|
|
|
115
115
|
|
|
116
116
|
def stimulus_controller_imports
|
|
117
117
|
[
|
|
118
|
-
'import { Navbar, TramwaySelect, UiCheckbox, Tooltip } from "@tramway/tramway"'
|
|
118
|
+
'import { Navbar, TramwaySelect, UiCheckbox, Tooltip, Collapsible } from "@tramway/tramway"'
|
|
119
119
|
]
|
|
120
120
|
end
|
|
121
121
|
|
|
@@ -124,7 +124,8 @@ module Tramway
|
|
|
124
124
|
"application.register('tramway-navbar', Navbar)",
|
|
125
125
|
"application.register('tramway-select', TramwaySelect)",
|
|
126
126
|
"application.register('ui--checkbox', UiCheckbox)",
|
|
127
|
-
"application.register('tramway-tooltip', Tooltip)"
|
|
127
|
+
"application.register('tramway-tooltip', Tooltip)",
|
|
128
|
+
"application.register('tramway--collapsible', Collapsible)"
|
|
128
129
|
]
|
|
129
130
|
end
|
|
130
131
|
|
|
@@ -299,7 +300,8 @@ module Tramway
|
|
|
299
300
|
"application.register('tramway-select'",
|
|
300
301
|
"application.register('table-row-preview'",
|
|
301
302
|
"application.register('ui--checkbox'",
|
|
302
|
-
"application.register('tramway-tooltip'"
|
|
303
|
+
"application.register('tramway-tooltip'",
|
|
304
|
+
"application.register('tramway--collapsible'"
|
|
303
305
|
]
|
|
304
306
|
end
|
|
305
307
|
|
data/lib/tramway/errors.rb
CHANGED
|
@@ -41,5 +41,25 @@ module Tramway
|
|
|
41
41
|
MESSAGE
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
|
+
|
|
45
|
+
# Raised when `params[:filter]` on an entities#index page does not match any of the
|
|
46
|
+
# filters declared for that page in the Tramway config, so the developer (or the user
|
|
47
|
+
# following a crafted link) gets a clear explanation instead of a raw NoMethodError from
|
|
48
|
+
# calling an arbitrary, possibly non-existent scope.
|
|
49
|
+
class InvalidFilterError < StandardError
|
|
50
|
+
def initialize(filter:, available_filters:, model_class:)
|
|
51
|
+
super(<<~MESSAGE)
|
|
52
|
+
Tramway received an unknown filter "#{filter}" for #{model_class} on the \
|
|
53
|
+
entities#index page.
|
|
54
|
+
|
|
55
|
+
Available filters: #{available_filters.join(', ')}.
|
|
56
|
+
|
|
57
|
+
To fix this:
|
|
58
|
+
- pass one of the available filters above in `params[:filter]`, or
|
|
59
|
+
- add `:#{filter}` to this entity's `filters:` list for the `:index` page in your \
|
|
60
|
+
Tramway config if it should be selectable.
|
|
61
|
+
MESSAGE
|
|
62
|
+
end
|
|
63
|
+
end
|
|
44
64
|
end
|
|
45
65
|
end
|
data/lib/tramway/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tramway
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.0.
|
|
4
|
+
version: 4.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kalashnikovisme
|
|
@@ -289,6 +289,7 @@ files:
|
|
|
289
289
|
- app/views/kaminari/_prev_page.html.haml
|
|
290
290
|
- app/views/tramway/chats/_message.html.haml
|
|
291
291
|
- app/views/tramway/chats/_messages.html.haml
|
|
292
|
+
- app/views/tramway/entities/_filters.html.haml
|
|
292
293
|
- app/views/tramway/entities/_form.html.haml
|
|
293
294
|
- app/views/tramway/entities/_list.html.haml
|
|
294
295
|
- app/views/tramway/entities/edit.html.haml
|