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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d8281006bde6f8928a12ffc94cef931ff7b3c4e8bdc774ad4e690b7a529a0e8
4
- data.tar.gz: 644f44899bc6919f0ea8f08fbb9708fa4fec87b54a398668714209f905a54d87
3
+ metadata.gz: d1254075cd4e025f5a4f46d4951fc391bd8c90a4c1a486b3bbe471b0d79cfebb
4
+ data.tar.gz: d37cb6b0408b55f635f75b8cc8f0fb90d5d5efa454a1db5c4303fdf800e45726
5
5
  SHA512:
6
- metadata.gz: 506f5ed78da723c554832b299ef0f0415f63b328fa9e4ff72e6f52b4972d699398a26b5101f869563eb91ce786cd244fd977cbab9f634d7bfdbbab7cef96dcff
7
- data.tar.gz: f68ae4382f2301f34ac572d725d41b0503f9ff5b6d2396e97a3afc95053d2c66e1f5818f2d9a0a6808f968e71ab386e3c789cbf9138f2616d0ee72f2af3c9104
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
- export { TramwaySelect, UiCheckbox, Tooltip, Navbar }
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
- if index_scope.present?
17
- model_class.public_send(index_scope)
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
- .flex.flex-row.justify-end.mt-2.gap-4
24
- - if @entity.page(:index).search
25
- = form_with url: public_send(custom_path_method), method: :get, local: true, builder: Tramway::Form::Builder, horizontal: true do |f|
26
- = f.text_field :query, label: false, value: params[:query], placeholder: t('tramway.actions.search')
27
- = f.submit nil, 'aria-label' => t('tramway.actions.search') do
28
- %i.fa.fa-search{ 'aria-hidden' => 'true' }
29
-
30
- = paginate @entities, custom_path_method:
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
@@ -14,6 +14,8 @@ en:
14
14
  save: "Save"
15
15
  cancel: "Cancel"
16
16
  search: "Search"
17
+ filters: "Filters"
18
+ clear_filter: "Clear filter"
17
19
  notices:
18
20
  created: "The record is created"
19
21
  updated: "The record is updated"
@@ -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
 
@@ -10,6 +10,7 @@ module Tramway
10
10
  attribute? :scope, Types::Coercible::String
11
11
  attribute? :search, Types::Bool
12
12
  attribute? :includes, Types::Array.default([].freeze)
13
+ attribute? :filters, Types::Array.default([].freeze)
13
14
  end
14
15
  end
15
16
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tramway
4
- VERSION = '4.0.1.4'
4
+ VERSION = '4.0.2'
5
5
  end
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.1.4
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