tramway 3.1.2.6 → 3.1.2.7

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: c9b234f727ff8de6c0d6f910d4132ae760f304db2b2e4fb211c06a1983264a18
4
- data.tar.gz: b94972e8d2862560cefca6f4343e15c3b362be56038426cda2b3a988b8705329
3
+ metadata.gz: 1bc6a1e219c499e701dd4ec09e3e04d722ba480744c25db62f4b8f5b75f3c365
4
+ data.tar.gz: 5fd9987b47419d936e0bd50dbf727147c149c33948188d53589cc3666482829b
5
5
  SHA512:
6
- metadata.gz: 59d8c55e6c5300996bc0250d106715647180320c0ab044a1e99dde08188cc887f5de4d8d2f175246c06a73e25f90c7dcea537ff58f0ff29a07f01ba278f346e9
7
- data.tar.gz: 5061f9357362cf45cfbfe5cc96ca937cdb4a1be8f58a6e156946c4ac2ea277ca3b3d5615f18fb66372a4ea33f57b39a71e33d15542ea2ff72ddf4b2bdf169ae3
6
+ metadata.gz: 7c4fb50d8f6579be7bb697af7a93b3e1b69199724e6ca23b1a54e03809bc8de896a43d2faf22dcfd0bc3f1755648689c888659c0ffcb19f90407a2036a2c0d44
7
+ data.tar.gz: cc2131f3a6f9302ab31db63ca04fce6339e770c9694a97d147e340e8294002ed1707bd5ed5c511b2e1293f369f23139bd57e6236d467fcc9a7f84b8bc0e00e68
data/README.md CHANGED
@@ -535,6 +535,86 @@ class UserDecorator < Tramway::BaseDecorator
535
535
  end
536
536
  ```
537
537
 
538
+ #### Show associations
539
+
540
+ Entity show pages can render associated records by returning association names from
541
+ the decorator's `show_associations` method. Each name must be an actual
542
+ Active Record association on the decorated model; Tramway raises a clear error
543
+ when the association is missing.
544
+
545
+ *app/models/post.rb*
546
+ ```ruby
547
+ class Post < ApplicationRecord
548
+ has_many :comments
549
+ end
550
+ ```
551
+
552
+ *app/decorators/post_decorator.rb*
553
+ ```ruby
554
+ class PostDecorator < Tramway::BaseDecorator
555
+ association :comments
556
+
557
+ def show_associations
558
+ %i[comments]
559
+ end
560
+ end
561
+ ```
562
+
563
+ Tramway renders a `New` button for a shown association when it can determine a
564
+ create path for that association. Define the associated model as a Tramway entity
565
+ with a `:create` page to let Tramway build that path automatically:
566
+
567
+ *config/initializers/tramway.rb*
568
+ ```ruby
569
+ Tramway.configure do |config|
570
+ config.entities = [
571
+ {
572
+ name: :post,
573
+ pages: [
574
+ { action: :show }
575
+ ]
576
+ },
577
+ {
578
+ name: :comment,
579
+ pages: [
580
+ { action: :create }
581
+ ]
582
+ }
583
+ ]
584
+ end
585
+ ```
586
+
587
+ When `new_record_path` is omitted, Tramway looks for a configured entity using
588
+ the association reflection's class name in underscore format. This means aliases
589
+ such as `has_many :responses, class_name: 'Comment'` use the `comment` entity,
590
+ not a `response` entity.
591
+
592
+ Generated association create paths include a `redirect` query parameter pointing
593
+ to the current record's configured Tramway entity show path, for example:
594
+ `/comments/new?redirect=%2Fposts%2F1`.
595
+
596
+ You can override the path explicitly when the associated record should be created
597
+ through a custom route:
598
+
599
+ ```ruby
600
+ class PostDecorator < Tramway::BaseDecorator
601
+ association :comments
602
+
603
+ def show_associations
604
+ [
605
+ [
606
+ :comments,
607
+ { new_record_path: Rails.application.routes.url_helpers.new_post_comment_path(object) }
608
+ ]
609
+ ]
610
+ end
611
+ end
612
+ ```
613
+
614
+ Tramway still appends the `redirect` query parameter to custom paths. Associations
615
+ without a create route and without `new_record_path` still render their records,
616
+ but no creation button is displayed.
617
+
538
618
  #### Decorate nil
539
619
 
540
620
  Tramway Decorator does not decorate nil objects
@@ -1253,10 +1333,12 @@ will render [this](https://play.tailwindcss.com/xho3LfjKkK)
1253
1333
  Use `size:` to control the input sizing (`:small`, `:medium`, or `:large`). The default is `:medium`, and supported inputs
1254
1334
  rendered within the form will use the same size value.
1255
1335
  Date and datetime fields open the browser's native picker when clicking anywhere in the input, not only the picker icon.
1336
+ Use `hint:` on any field to render muted helper text below the input. The hint option is handled by Tramway and is not
1337
+ forwarded as an HTML attribute.
1256
1338
 
1257
1339
  ```erb
1258
1340
  <%= tramway_form_for @user, size: :large do |f| %>
1259
- <%= f.text_field :text %>
1341
+ <%= f.text_field :text, hint: 'This name is visible to teammates.' %>
1260
1342
  <%= f.submit 'Create User' %>
1261
1343
  <% end %>
1262
1344
  ```
@@ -10,6 +10,7 @@ class TailwindComponent < Tramway::BaseComponent
10
10
  option :options
11
11
  option :label
12
12
  option :for
13
+ option :hint, optional: true, default: -> {}
13
14
  option :size, default: -> { :medium }
14
15
 
15
16
  SIZE_CLASSES = {
@@ -83,6 +84,10 @@ class TailwindComponent < Tramway::BaseComponent
83
84
  'peer-disabled:opacity-70'
84
85
  end
85
86
 
87
+ def form_hint_classes
88
+ 'mt-1 text-sm text-zinc-400'
89
+ end
90
+
86
91
  def default_container_classes
87
92
  return if options[:horizontal]
88
93
 
@@ -210,11 +210,16 @@ module Tramway
210
210
  attribute:,
211
211
  label: label_build(attribute, options),
212
212
  for: options[:id].presence || for_id(attribute),
213
+ hint: hint_option(options),
213
214
  options: options,
214
215
  size: form_size
215
216
  }
216
217
  end
217
218
 
219
+ def hint_option(options)
220
+ options.delete(:hint) || options.delete('hint')
221
+ end
222
+
218
223
  def label_build(attribute, options)
219
224
  label_option = options[:label]
220
225
 
@@ -1,29 +1,32 @@
1
- .flex.items-center.gap-2{ class: default_container_classes, data: { controller: 'ui--checkbox' } }
2
- - checkbox_checked = checked?
3
- = @input.call @attribute, **hidden_checkbox_options
4
- %button{ type: 'button',
5
- role: 'checkbox',
6
- 'aria-checked' => checkbox_checked.to_s,
7
- 'data-action' => 'click->ui--checkbox#toggle',
8
- 'data-state' => checkbox_checked ? 'checked' : 'unchecked',
9
- 'data-ui--checkbox-target' => 'button',
10
- value: 'on',
11
- class: checkbox_button_classes,
12
- style: checkbox_button_style,
13
- for: @for,
14
- id: @for }
15
- %span{ class: checkbox_indicator_classes, style: 'pointer-events: none', data: { 'ui--checkbox-target' => 'indicator' } }
16
- %svg{ xmlns: 'http://www.w3.org/2000/svg',
17
- width: '24',
18
- height: '24',
19
- viewBox: '0 0 24 24',
20
- fill: 'none',
21
- stroke: 'currentColor',
22
- 'stroke-width' => '2',
23
- 'stroke-linecap' => 'round',
24
- 'stroke-linejoin' => 'round',
25
- class: size_class(:checkbox_indicator) }
26
- %polyline{ points: '20 6 9 17 4 12' }
27
- - if @label
28
- = component('tramway/form/label', for: @for, options: { class: label_classes, style: label_style, data: { action: 'click->ui--checkbox#toggle' } }) do
29
- = @label
1
+ %div{ class: default_container_classes, data: { controller: 'ui--checkbox' } }
2
+ .flex.items-center.gap-2
3
+ - checkbox_checked = checked?
4
+ = @input.call @attribute, **hidden_checkbox_options
5
+ %button{ type: 'button',
6
+ role: 'checkbox',
7
+ 'aria-checked' => checkbox_checked.to_s,
8
+ 'data-action' => 'click->ui--checkbox#toggle',
9
+ 'data-state' => checkbox_checked ? 'checked' : 'unchecked',
10
+ 'data-ui--checkbox-target' => 'button',
11
+ value: 'on',
12
+ class: checkbox_button_classes,
13
+ style: checkbox_button_style,
14
+ for: @for,
15
+ id: @for }
16
+ %span{ class: checkbox_indicator_classes, style: 'pointer-events: none', data: { 'ui--checkbox-target' => 'indicator' } }
17
+ %svg{ xmlns: 'http://www.w3.org/2000/svg',
18
+ width: '24',
19
+ height: '24',
20
+ viewBox: '0 0 24 24',
21
+ fill: 'none',
22
+ stroke: 'currentColor',
23
+ 'stroke-width' => '2',
24
+ 'stroke-linecap' => 'round',
25
+ 'stroke-linejoin' => 'round',
26
+ class: size_class(:checkbox_indicator) }
27
+ %polyline{ points: '20 6 9 17 4 12' }
28
+ - if @label
29
+ = component('tramway/form/label', for: @for, options: { class: label_classes, style: label_style, data: { action: 'click->ui--checkbox#toggle' } }) do
30
+ = @label
31
+ - if @hint.present?
32
+ %p{ class: form_hint_classes }= @hint
@@ -4,3 +4,5 @@
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **picker_options(classes), value: @value
7
+ - if @hint.present?
8
+ %p{ class: form_hint_classes }= @hint
@@ -4,3 +4,5 @@
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **picker_options(classes), value: @value
7
+ - if @hint.present?
8
+ %p{ class: form_hint_classes }= @hint
@@ -5,3 +5,5 @@
5
5
  %label{ for: @for, class: classes }
6
6
  = @label
7
7
  = @input
8
+ - if @hint.present?
9
+ %p{ class: form_hint_classes }= @hint
@@ -4,3 +4,5 @@
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
7
+ - if @hint.present?
8
+ %p{ class: form_hint_classes }= @hint
@@ -12,3 +12,5 @@
12
12
  = component('tramway/form/label', for: @for, class: 'mb-0') do
13
13
  = @label
14
14
  = @input.call @attribute, **rich_text_area_options
15
+ - if @hint.present?
16
+ %p{ class: form_hint_classes }= @hint
@@ -4,3 +4,5 @@
4
4
  = @label
5
5
  - classes = "#{size_class(:select_input)} #{select_base_classes}"
6
6
  = @input.call(@attribute, @collection, { selected: @value }, @options.merge(class: classes))
7
+ - if @hint.present?
8
+ %p{ class: form_hint_classes }= @hint
@@ -4,3 +4,5 @@
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
7
+ - if @hint.present?
8
+ %p{ class: form_hint_classes }= @hint
@@ -4,3 +4,5 @@
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
7
+ - if @hint.present?
8
+ %p{ class: form_hint_classes }= @hint
@@ -4,3 +4,5 @@
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
7
+ - if @hint.present?
8
+ %p{ class: form_hint_classes }= @hint
@@ -11,3 +11,5 @@
11
11
  = component 'tramway/form/tramway_select/caret', size:, direction: :down
12
12
  .caret-up.hidden{ data: { "tramway-select-target" => "caretUp" } }
13
13
  = component 'tramway/form/tramway_select/caret', size:, direction: :up
14
+ - if @hint.present?
15
+ %p{ class: form_hint_classes }= @hint
@@ -91,35 +91,7 @@ module Tramway
91
91
  end
92
92
 
93
93
  def set_associations
94
- @associations = @record.show_associations.map do |(association, options)|
95
- options ||= {}
96
- association_type = @record.object.class.reflect_on_association(association).macro
97
-
98
- {
99
- name: association,
100
- association_type:,
101
- **send("#{association_type}_associations", association, options)
102
- }
103
- end.compact
104
- end
105
-
106
- # rubocop:disable Naming/PredicatePrefix
107
- def has_many_associations(association, options)
108
- records = Kaminari.paginate_array(@record.public_send(association.name)).page(params[:page])
109
-
110
- {
111
- decorator: records.first&.class,
112
- records:,
113
- model_class: records.first&.object&.class,
114
- new_record_path: options[:new_record_path]
115
- }
116
- end
117
- # rubocop:enable Naming/PredicatePrefix
118
-
119
- def belongs_to_associations(association, _options)
120
- record = @record.public_send(association.name)
121
-
122
- { decorator: record.class, record:, model_class: record.class }
94
+ @associations = @record.send(:__show_associations, params[:page])
123
95
  end
124
96
 
125
97
  def search(entities)
@@ -52,9 +52,10 @@
52
52
  custom_path_method: @entity.index_helper_method,
53
53
  custom_path_arguments: [@record.id]
54
54
 
55
- = tramway_button text: t('tramway.actions.new'),
56
- path: association[:new_record_path],
57
- type: :will
55
+ - if association[:new_record_path].present?
56
+ = tramway_button text: t('tramway.actions.new'),
57
+ path: association[:new_record_path],
58
+ type: :will
58
59
 
59
60
  - if association[:records].any?
60
61
  = tramway_table class: 'mt-4' do
data/docs/AGENTS.md CHANGED
@@ -712,12 +712,15 @@ end
712
712
 
713
713
  ```ruby
714
714
  = tramway_form_for @user do |f|
715
- = f.email_field :email
715
+ = f.email_field :email, hint: 'Use the address where you receive account notifications.'
716
716
  = f.password_field :password
717
717
  = f.select :role, [["Admin", "admin"], ["Manager", "manager"]], include_blank: "Select role"
718
718
  = f.submit 'Save'
719
719
  ```
720
720
 
721
+ Use `hint:` on fields when short helper text should appear below the input. Tramway renders the hint separately and does
722
+ not forward it to the input as an HTML attribute.
723
+
721
724
  Autocomplete select example:
722
725
 
723
726
  ```ruby
@@ -3,6 +3,7 @@
3
3
  require 'tramway/decorators/name_builder'
4
4
  require 'tramway/decorators/association'
5
5
  require 'tramway/decorators/collection_decorator'
6
+ require 'tramway/decorators/show_associations'
6
7
  require 'tramway/helpers/decorate_helper'
7
8
  require 'tramway/helpers/component_helper'
8
9
  require 'tramway/helpers/views_helper'
@@ -14,6 +15,7 @@ module Tramway
14
15
  #
15
16
  class BaseDecorator
16
17
  include Tramway::Decorators::CollectionDecorators
18
+ include Tramway::Decorators::ShowAssociations
17
19
  include Tramway::Utils::Render
18
20
  include Tramway::DuckTyping::ActiveRecordCompatibility
19
21
  include Tramway::Helpers::DecorateHelper
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/utils'
4
+
5
+ module Tramway
6
+ module Decorators
7
+ # Builds metadata for associations rendered on Tramway entity show pages.
8
+ module ShowAssociations
9
+ private
10
+
11
+ def __show_associations(page)
12
+ show_associations.map do |(association, options)|
13
+ __show_association(association, options || {}, page)
14
+ end.compact
15
+ end
16
+
17
+ def __show_association(association, options, page)
18
+ reflection = __reflection_for_show_association(association)
19
+ association_type = reflection.macro
20
+ association_options = __association_options(association, association_type, options, page)
21
+ new_record_path = __association_new_record_path(reflection, association_options.delete(:new_record_path))
22
+
23
+ {
24
+ name: association,
25
+ association_type:,
26
+ new_record_path:,
27
+ **association_options
28
+ }
29
+ end
30
+
31
+ def __reflection_for_show_association(association)
32
+ reflection = object.class.reflect_on_association(association)
33
+
34
+ return reflection if reflection.present?
35
+
36
+ raise "You must define #{association} association in the #{object.class}"
37
+ end
38
+
39
+ def __association_options(association, association_type, options, page)
40
+ send("__#{association_type}_associations", association, options, page)
41
+ end
42
+
43
+ def __association_new_record_path(reflection, new_record_path)
44
+ new_record_path = __inferred_association_new_record_path(reflection) if new_record_path.blank?
45
+
46
+ return if new_record_path.blank?
47
+
48
+ __new_record_path_with_redirect(new_record_path)
49
+ end
50
+
51
+ def __inferred_association_new_record_path(reflection)
52
+ association_entity = __entity_for_name(reflection.class_name.underscore)
53
+
54
+ return if association_entity.blank? || association_entity.page(:create).blank?
55
+
56
+ Tramway::Engine.routes.url_helpers.public_send(association_entity.new_helper_method)
57
+ end
58
+
59
+ def __new_record_path_with_redirect(new_record_path)
60
+ redirect_path = __entity_show_path
61
+
62
+ return new_record_path if redirect_path.blank?
63
+
64
+ separator = new_record_path.include?('?') ? '&' : '?'
65
+
66
+ "#{new_record_path}#{separator}#{Rack::Utils.build_query(redirect: redirect_path)}"
67
+ end
68
+
69
+ def __entity_show_path
70
+ entity = __entity_for_name(object.class.name.underscore)
71
+
72
+ return if entity.blank? || entity.page(:show).blank?
73
+
74
+ Tramway::Engine.routes.url_helpers.public_send(entity.show_helper_method, object.id)
75
+ end
76
+
77
+ def __entity_for_name(name)
78
+ Tramway.config.entities.find do |config_entity|
79
+ config_entity.name == name
80
+ end
81
+ end
82
+
83
+ def __has_many_associations(association, options, page)
84
+ records = Kaminari.paginate_array(public_send(association.name)).page(page)
85
+
86
+ {
87
+ decorator: records.first&.class,
88
+ records:,
89
+ model_class: records.first&.object&.class,
90
+ new_record_path: options[:new_record_path]
91
+ }
92
+ end
93
+
94
+ def __belongs_to_associations(association, _options, _page)
95
+ record = public_send(association.name)
96
+
97
+ { decorator: record.class, record:, model_class: record.class }
98
+ end
99
+ end
100
+ end
101
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tramway
4
- VERSION = '3.1.2.6'
4
+ VERSION = '3.1.2.7'
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: 3.1.2.6
4
+ version: 3.1.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - kalashnikovisme
@@ -282,6 +282,7 @@ files:
282
282
  - lib/tramway/decorators/class_helper.rb
283
283
  - lib/tramway/decorators/collection_decorator.rb
284
284
  - lib/tramway/decorators/name_builder.rb
285
+ - lib/tramway/decorators/show_associations.rb
285
286
  - lib/tramway/duck_typing.rb
286
287
  - lib/tramway/duck_typing/active_record_compatibility.rb
287
288
  - lib/tramway/engine.rb