tramway 4.0 → 4.0.1.1

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: 148d9d2e8ff5ee611788d8242c4900f20b80d85b853fd3840b16d0d5e46499a2
4
- data.tar.gz: 62f5e3d6272692fc77356597957d79a69fa027b1cb13882e9178fea55b466681
3
+ metadata.gz: c7d9ecf679835522a59184b0ffdbc6edf5cfa66aa5f1126429828497b3115e12
4
+ data.tar.gz: efa55f1a6971b64000f41f387b6a85b7d41756b4c5240c35ce3f283f6f878f4c
5
5
  SHA512:
6
- metadata.gz: d70a7f1a483a8633c3c479d5fc9fd089f185f23b6c4e554113fdb8461f8ca09586bebf2765e7a35b0e52985fe63e020db0185b9ce9e82f5721b3944715c3d1e7
7
- data.tar.gz: e117993ffe8ad1c3448975ee7944aab1b1138ab0132ca3cec0c2f56eee999f27f1e90f4d86a4b20401e02e9281def6d9fa35e9b351fb510e32e58647a0db9e75
6
+ metadata.gz: 67ac2fd8508f6a2d172638f3ac7f808721e5d4c1aa24f13e247238196c1fc90cdc5ddc1e0f7d5aa41cf13c1e47d0fa0d118d807f073afa1372ebc83b9b0f873b
7
+ data.tar.gz: 2bd0d71dbade50601a643f2c6c3567b00440d2104db60f53fc0f1a3937c73c77fde8509a8cddea9b68c908dfe92214c036e6b14bee02919c9a1a0b2f4ef5b1be
data/README.md CHANGED
@@ -219,8 +219,47 @@ Tramway.configure do |config|
219
219
  end
220
220
  ```
221
221
 
222
- When search is enabled, Tramway uses `Model.search(query)` if defined. If not, it falls back to `Model.tramway_search(query)` and logs a warning.
223
- The fallback is generic and not tailored to your data structure, so it is not intended for long-term use and may be slow or not scalable.
222
+ When search is enabled, a text input and a search icon button appear at the top of the index page. Tramway uses
223
+ `Model.search(query)` if you define it (e.g. a `pg_search_scope` or any custom scope named `search`). If not, it falls back
224
+ to `Model.tramway_search(query)`, which searches across all string/text columns of the model using the [`pg_search`](https://github.com/Casecommons/pg_search)
225
+ gem, and logs a warning. The fallback is generic and not tailored to your data structure, so it is not intended for
226
+ long-term use and may be slow or not scalable.
227
+
228
+ `pg_search` requires PostgreSQL's full-text search functions. If your application's database is not PostgreSQL, calling
229
+ the fallback raises `Tramway::Errors::UnsupportedDatabaseAdapterError` with an explanation and a suggestion to define your
230
+ own `Model.search(query)` scope instead.
231
+
232
+ `bin/rails g tramway:install` adds `gem "pg_search"` to your Gemfile automatically (like Tramway's other dependencies), so
233
+ run it (and `bundle install`) if you see the fallback raise `Tramway::Errors::MissingGemError`. That error also fires if
234
+ `pg_search` can't otherwise be loaded, and explains how to fix it or define your own `Model.search(query)` scope.
235
+
236
+ **includes**
237
+
238
+ If a decorator's `index_attributes` reads an association (e.g. `object.comments.map(&:text)`), each row triggers its own
239
+ query unless the association is eager-loaded. Set `includes` on the `:index` page entry to preload it, using the same
240
+ shape Rails' `.includes` accepts (a symbol, array, or nested hash):
241
+
242
+ *config/initializers/tramway.rb*
243
+ ```ruby
244
+ Tramway.configure do |config|
245
+ config.entities = [
246
+ {
247
+ name: :campaign,
248
+ namespace: :admin,
249
+ pages: [
250
+ {
251
+ action: :index,
252
+ includes: [:user, :comments]
253
+ }
254
+ ]
255
+ }
256
+ ]
257
+ end
258
+ ```
259
+
260
+ In this example, Tramway calls `Campaign.includes(:user, :comments)` before applying `scope`/`search`, so accessing
261
+ `campaign.user` or `campaign.comments` in the decorator's `index_attributes` does not issue an extra query per row.
262
+ Omitting `includes` produces identical behavior to before this option existed.
224
263
 
225
264
  **show page**
226
265
 
@@ -946,6 +985,23 @@ renders the classic top navbar, and desktop `:vertical` renders the collapsible
946
985
  When `tramway_navbar` renders before `tramway_main_container`, the main container helper keeps the vertical
947
986
  sidebar offset in sync automatically and removes it for horizontal pages.
948
987
 
988
+ The collapsed/expanded state of the vertical sidebar persists across page navigations. The Stimulus controller
989
+ saves the state to the browser's `localStorage` (key `tramway-navbar-expanded`) whenever the toggle button is
990
+ used, and reads it back on every page load, so collapsing the sidebar on one page keeps it collapsed everywhere
991
+ else until it's expanded again. This is per-browser state: it isn't synced across devices or persisted server-side,
992
+ and if `localStorage` is unavailable (e.g. private browsing with storage blocked), the sidebar simply falls back
993
+ to always starting expanded.
994
+
995
+ Since the server always renders the sidebar expanded (it has no way to know the browser's stored preference) and
996
+ the Stimulus controller only applies the collapsed classes once it connects, `tramway_navbar` also renders a small
997
+ blocking inline `<script>` alongside its markup. It reads `localStorage` synchronously, before the browser paints,
998
+ and — if the sidebar was left collapsed — sets a `data-tramway-navbar-expanded="false"` attribute on `<html>`. A
999
+ matching `@media (min-width: 768px)` CSS block (rendered alongside the navbar) keys off that attribute to apply the
1000
+ collapsed width, main-container padding, and faded header/menu immediately, before any JavaScript module has had a
1001
+ chance to load and run. This is what prevents the sidebar from flashing expanded and then snapping to collapsed on
1002
+ page load. The Stimulus controller keeps that `<html>` attribute in sync afterwards (on load and on every toggle),
1003
+ so this pre-paint styling and the controller's own classes never fight each other.
1004
+
949
1005
  **NOTE:** `tramway_navbar` method called without arguments and block of code will render only [Tramway CRUD](https://github.com/Purple-Magic/tramway#tramway-crud) links on the left.
950
1006
 
951
1007
  In case you want to hide entity links you can pass `with_entities: false`.
@@ -467,6 +467,7 @@ class Navbar extends Controller {
467
467
  this.desktopMainExpandedPaddingClass = 'md:pl-72'
468
468
  this.desktopMainCollapsedPaddingClass = 'md:pl-24'
469
469
  this.hiddenInteractionClasses = ['opacity-0', 'pointer-events-none']
470
+ this.expandedStorageKey = 'tramway-navbar-expanded'
470
471
 
471
472
  this.desktopNavbar = this.element
472
473
  this.desktopHeader = document.getElementById('desktop-navbar-header')
@@ -490,8 +491,22 @@ class Navbar extends Controller {
490
491
  document.addEventListener('turbo:before-cache', this.handleBeforeCache)
491
492
  window.addEventListener('resize', this.handleResize)
492
493
 
493
- if (this.isDesktopViewport()) {
494
- this.syncDesktopExpandedState()
494
+ this.syncDesktopExpandedState()
495
+ }
496
+
497
+ readStoredExpanded() {
498
+ try {
499
+ return window.localStorage.getItem(this.expandedStorageKey)
500
+ } catch {
501
+ return null
502
+ }
503
+ }
504
+
505
+ writeStoredExpanded(expanded) {
506
+ try {
507
+ window.localStorage.setItem(this.expandedStorageKey, expanded ? 'true' : 'false')
508
+ } catch {
509
+ /* localStorage unavailable (e.g. private mode) — state simply won't persist */
495
510
  }
496
511
  }
497
512
 
@@ -579,18 +594,31 @@ class Navbar extends Controller {
579
594
  }
580
595
 
581
596
  toggleDesktopExpanded() {
582
- this.setDesktopExpanded(this.desktopNavbar.dataset.expanded === 'false')
597
+ this.setDesktopExpanded(this.desktopNavbar.dataset.expanded === 'false', { persist: true })
583
598
  }
584
599
 
585
600
  syncDesktopExpandedState() {
586
- this.setDesktopExpanded(this.desktopNavbar.dataset.expanded !== 'false')
601
+ const storedExpanded = this.readStoredExpanded()
602
+ const expanded = storedExpanded === null ? this.desktopNavbar.dataset.expanded !== 'false' : storedExpanded !== 'false'
603
+
604
+ this.setDesktopExpanded(expanded)
587
605
  }
588
606
 
589
- setDesktopExpanded(expanded) {
607
+ setDesktopExpanded(expanded, { persist = false } = {}) {
590
608
  if (!this.isVertical() || !this.desktopNavbar || !this.desktopHeader || !this.desktopMenu || !this.desktopMainContainer || !this.desktopToggleButton || !this.desktopToggleWrapper) {
591
609
  return
592
610
  }
593
611
 
612
+ if (persist) {
613
+ this.writeStoredExpanded(expanded)
614
+ }
615
+
616
+ if (expanded) {
617
+ document.documentElement.removeAttribute('data-tramway-navbar-expanded')
618
+ } else {
619
+ document.documentElement.setAttribute('data-tramway-navbar-expanded', 'false')
620
+ }
621
+
594
622
  this.desktopNavbar.classList.toggle(this.desktopNavbarExpandedWidthClass, expanded)
595
623
  this.desktopNavbar.classList.toggle(this.desktopNavbarCollapsedWidthClass, !expanded)
596
624
  this.desktopNavbar.dataset.expanded = expanded ? 'true' : 'false'
@@ -102,6 +102,17 @@
102
102
  Close
103
103
 
104
104
  - if vertical?
105
+ :javascript
106
+ (function () {
107
+ try {
108
+ if (window.localStorage.getItem('tramway-navbar-expanded') === 'false') {
109
+ document.documentElement.setAttribute('data-tramway-navbar-expanded', 'false');
110
+ } else {
111
+ document.documentElement.removeAttribute('data-tramway-navbar-expanded');
112
+ }
113
+ } catch (e) {}
114
+ })();
115
+
105
116
  :css
106
117
  .tramway-navbar-desktop-vertical .tramway-navbar-item {
107
118
  width: 100%;
@@ -121,3 +132,27 @@
121
132
  width: 100%;
122
133
  justify-content: flex-start;
123
134
  }
135
+
136
+ @media (min-width: 768px) {
137
+ html[data-tramway-navbar-expanded="false"] #desktop-navbar {
138
+ width: 6rem;
139
+ }
140
+
141
+ html[data-tramway-navbar-expanded="false"] #tramway-main-container {
142
+ padding-left: 6rem;
143
+ }
144
+
145
+ html[data-tramway-navbar-expanded="false"] #desktop-navbar-header,
146
+ html[data-tramway-navbar-expanded="false"] #desktop-navbar-content {
147
+ opacity: 0;
148
+ pointer-events: none;
149
+ }
150
+
151
+ html[data-tramway-navbar-expanded="false"] #desktop-navbar-toggle-wrapper {
152
+ justify-content: center;
153
+ }
154
+
155
+ html[data-tramway-navbar-expanded="false"] #desktop-navbar-toggle-icon {
156
+ transform: rotate(180deg);
157
+ }
158
+ }
@@ -19,6 +19,7 @@ module Tramway
19
19
  model_class.order(id: :desc)
20
20
  end => entities
21
21
 
22
+ entities = preload(entities)
22
23
  entities = search(entities)
23
24
  entities = entities.page(params[:page])
24
25
  @entities = entities
@@ -90,6 +91,12 @@ module Tramway
90
91
  entity.page(:index).scope
91
92
  end
92
93
 
94
+ def preload(entities)
95
+ includes = entity.page(:index).includes
96
+
97
+ includes.present? ? entities.includes(includes) : entities
98
+ end
99
+
93
100
  def set_associations
94
101
  @associations = @record.send(:__show_associations, params[:page])
95
102
  end
@@ -22,7 +22,8 @@
22
22
  - if @entity.page(:index).search
23
23
  = form_with url: public_send(custom_path_method), method: :get, local: true, builder: Tramway::Form::Builder, horizontal: true do |f|
24
24
  = f.text_field :query, label: false, value: params[:query], placeholder: t('tramway.actions.search')
25
- = f.submit t('tramway.actions.search')
25
+ = f.submit nil, 'aria-label' => t('tramway.actions.search') do
26
+ %i.fa.fa-search{ 'aria-hidden' => 'true' }
26
27
 
27
28
  = paginate @entities, custom_path_method:
28
29
 
@@ -51,7 +51,8 @@ module Tramway
51
51
  { name: 'kaminari', declaration: 'gem "kaminari"' },
52
52
  { name: 'view_component', declaration: 'gem "view_component"' },
53
53
  { name: 'dry-initializer', declaration: "gem 'dry-initializer'" },
54
- { name: 'dry-monads', declaration: "gem 'dry-monads'" }
54
+ { name: 'dry-monads', declaration: "gem 'dry-monads'" },
55
+ { name: 'pg_search', declaration: "gem 'pg_search'" }
55
56
  ]
56
57
  end
57
58
 
@@ -9,6 +9,7 @@ module Tramway
9
9
  attribute :action, Types::Coercible::String
10
10
  attribute? :scope, Types::Coercible::String
11
11
  attribute? :search, Types::Bool
12
+ attribute? :includes, Types::Array.default([].freeze)
12
13
  end
13
14
  end
14
15
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ # Namespace for Tramway-specific error classes
5
+ module Errors
6
+ # Raised when a Tramway feature depends on infrastructure (e.g. a specific
7
+ # database adapter) that the current application does not provide, so the
8
+ # developer gets an actionable error instead of a raw, confusing failure
9
+ # deep inside the underlying gem.
10
+ class UnsupportedDatabaseAdapterError < StandardError
11
+ def initialize(feature:, model_class:, adapter:, required_adapter: 'PostgreSQL')
12
+ super(<<~MESSAGE)
13
+ Tramway #{feature} requires #{required_adapter}, but #{model_class} is connected \
14
+ through the "#{adapter}" adapter.
15
+
16
+ This feature relies on the `pg_search` gem, which needs PostgreSQL's full-text \
17
+ search functions (to_tsvector/to_tsquery) and does not work on other adapters.
18
+
19
+ To fix this:
20
+ - switch #{model_class}'s database connection to PostgreSQL, or
21
+ - define a custom `#{model_class}.search(query)` scope that works with your \
22
+ adapter; Tramway will use it instead of the built-in pg_search fallback.
23
+ MESSAGE
24
+ end
25
+ end
26
+
27
+ # Raised when a Tramway feature depends on a gem that could not be loaded or used, so the
28
+ # developer (or an AI agent) gets a precise, actionable fix instead of a raw NoMethodError
29
+ # or LoadError deep inside the missing gem's call chain.
30
+ class MissingGemError < StandardError
31
+ def initialize(feature:, model_class:, gem_name:, original_error:)
32
+ super(<<~MESSAGE)
33
+ Tramway #{feature} for #{model_class} could not use the `#{gem_name}` gem \
34
+ (#{original_error.class}: #{original_error.message}).
35
+
36
+ To fix this:
37
+ - add `gem "#{gem_name}"` to your Gemfile, run `bundle install`, and restart your \
38
+ server (or re-run `bin/rails g tramway:install`, which adds it automatically), or
39
+ - define a custom `#{model_class}.search(query)` scope that does not depend on \
40
+ `#{gem_name}`; Tramway will use it instead of the built-in fallback.
41
+ MESSAGE
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tramway/errors'
4
+
5
+ module Tramway
6
+ # Wires up a default, generic search across all string/text columns using the
7
+ # `pg_search` gem, so entities#index search works out of the box for any model
8
+ # that does not define its own `.search` scope.
9
+ #
10
+ # This is only supported on PostgreSQL, since pg_search depends on PostgreSQL's
11
+ # full-text search functions. On any other adapter it raises a clear,
12
+ # actionable error instead of failing with a confusing SQL error deep inside
13
+ # pg_search.
14
+ module PgSearchable
15
+ module_function
16
+
17
+ def call(model_class, query)
18
+ ensure_postgres!(model_class)
19
+ ensure_pg_search_scope!(model_class)
20
+
21
+ model_class.tramway_pg_search(query)
22
+ end
23
+
24
+ def ensure_postgres!(model_class)
25
+ adapter = model_class.connection.adapter_name
26
+
27
+ return if adapter.casecmp('postgresql').zero?
28
+
29
+ raise Tramway::Errors::UnsupportedDatabaseAdapterError.new(
30
+ feature: 'the default entities#index search',
31
+ model_class:,
32
+ adapter:
33
+ )
34
+ end
35
+
36
+ def ensure_pg_search_scope!(model_class)
37
+ return if model_class.respond_to?(:tramway_pg_search)
38
+
39
+ define_pg_search_scope!(model_class)
40
+ rescue LoadError, NameError => e
41
+ raise missing_pg_search_gem_error(model_class, e)
42
+ end
43
+
44
+ def define_pg_search_scope!(model_class)
45
+ require 'pg_search'
46
+
47
+ model_class.include(PgSearch::Model)
48
+ model_class.pg_search_scope :tramway_pg_search,
49
+ against: model_class.searchable_column_names,
50
+ using: { tsearch: { prefix: true } }
51
+ end
52
+
53
+ def missing_pg_search_gem_error(model_class, original_error)
54
+ Tramway::Errors::MissingGemError.new(
55
+ feature: 'the default entities#index search',
56
+ model_class:,
57
+ gem_name: 'pg_search',
58
+ original_error:
59
+ )
60
+ end
61
+ end
62
+ end
@@ -1,43 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'tramway/pg_searchable'
4
+
3
5
  module Tramway
4
- # Searchable module provides a class method `tramway_search` for ActiveRecord models to perform full-text search
5
- # across all string and text columns using PostgreSQL's full-text search capabilities.
6
+ # Searchable module provides a class method `tramway_search` for ActiveRecord models to
7
+ # perform a generic search across all string and text columns, using the `pg_search` gem.
8
+ # It is the fallback Tramway::EntitiesController uses when a model does not define its
9
+ # own `.search` scope. See Tramway::PgSearchable for the PostgreSQL requirement.
6
10
  module Searchable
7
11
  extend ActiveSupport::Concern
8
12
 
9
13
  class_methods do
10
14
  def tramway_search(query)
11
- tokens = search_tokens(query)
12
- columns_to_search = searchable_column_names
13
-
14
- return all if tokens.empty? || columns_to_search.empty?
15
-
16
- where(
17
- Arel.sql("to_tsvector('simple', #{tsvector_expression(columns_to_search)}) @@ to_tsquery('simple', ?)"),
18
- tsquery_expression(tokens)
19
- )
20
- end
21
-
22
- private
15
+ return all if query.blank?
23
16
 
24
- def search_tokens(query)
25
- query.to_s.scan(/[[:alnum:]]+/)
17
+ Tramway::PgSearchable.call(self, query)
26
18
  end
27
19
 
28
20
  def searchable_column_names
29
21
  columns.select { |column| column.type.in?(%i[string text]) }.map(&:name)
30
22
  end
31
-
32
- def tsvector_expression(column_names)
33
- column_names.map do |column_name|
34
- "coalesce(#{connection.quote_column_name(column_name)}, '')"
35
- end.join(" || ' ' || ")
36
- end
37
-
38
- def tsquery_expression(tokens)
39
- tokens.map { |token| "#{token}:*" }.join(' & ')
40
- end
41
23
  end
42
24
  end
43
25
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tramway
4
- VERSION = '4.0'
4
+ VERSION = '4.0.1.1'
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'
4
+ version: 4.0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - kalashnikovisme
@@ -94,6 +94,20 @@ dependencies:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: pg_search
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: rails
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -309,6 +323,7 @@ files:
309
323
  - lib/tramway/duck_typing.rb
310
324
  - lib/tramway/duck_typing/active_record_compatibility.rb
311
325
  - lib/tramway/engine.rb
326
+ - lib/tramway/errors.rb
312
327
  - lib/tramway/forms/class_helper.rb
313
328
  - lib/tramway/forms/fields.rb
314
329
  - lib/tramway/forms/normalizations.rb
@@ -322,6 +337,7 @@ files:
322
337
  - lib/tramway/helpers/table_helper.rb
323
338
  - lib/tramway/helpers/views_helper.rb
324
339
  - lib/tramway/navbar.rb
340
+ - lib/tramway/pg_searchable.rb
325
341
  - lib/tramway/searchable.rb
326
342
  - lib/tramway/utils/field.rb
327
343
  - lib/tramway/utils/render.rb