tramway 3.0.2 → 3.0.3

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: def2792e8c3e1ebae5e4d2fa04b7e5edf92b72e4aed0f892cf8152e203c8daf2
4
- data.tar.gz: 77eace6936af7c9597f49634ea5da28f699b4011f4ccac811259e659732ca192
3
+ metadata.gz: 1da050191fdef1006bf62ea51fe4d11465eb809c273dc681f55f8937243e53fa
4
+ data.tar.gz: bce78a50e8ce1fdc3b8a126f3797841c5883c3b1810c370abfb00ba9609cc1e9
5
5
  SHA512:
6
- metadata.gz: d6f7a8ed6f51a4658625b8df8f1db6ec2eb7619284eafc6acf0b46d67f0fb9eaf3bfe95e818d7226f52a93a6b81eb271e64335acf76640501132b890e000938d
7
- data.tar.gz: 197a0227f09dbccacf37e82d2e5d15a585928b0ec60863c1ce43c20e012b568037e0880f9ffda49a21846c66dcec72f6b1931ff0790509d9c01b6b8da71727d8
6
+ metadata.gz: 6d7c33ecf3b2544fab033a332e303dfd1cb23fa40dfb4adf8201a702be929ad702c96e5246055d59c88fe4c67e74d857590989ad581b61f398be05fd1ebdaac3
7
+ data.tar.gz: 88a49d7eedda98eb23455e4042b21ce67fc8972a0509e780e8742937b73705ae9cfa6d14fb236b3a1da1ef478e384cba009f776c1711bccd5c98d104ae20d32e
data/README.md CHANGED
@@ -987,6 +987,20 @@ Use the optional `href:` argument on `tramway_row` to turn an entire row into a
987
987
  <% end %>
988
988
  ```
989
989
 
990
+ `tramway_row` also accepts `preview:`. By default preview is enabled (`true`) for non-linked rows and renders the mobile slide-up
991
+ details panel. Pass `preview: false` when you want a row without the preview panel.
992
+
993
+ ```erb
994
+ <%= tramway_row preview: false do %>
995
+ <%= tramway_cell do %>
996
+ <%= user.name %>
997
+ <% end %>
998
+ <%= tramway_cell do %>
999
+ <%= user.email %>
1000
+ <% end %>
1001
+ <% end %>
1002
+ ```
1003
+
990
1004
  When you render a header you can either pass the `headers:` array, as in the examples above, or render custom header content in
991
1005
  the block. `tramway_header` uses the length of the `headers` array to build the grid if the array is present.
992
1006
  If you omit the array and provide custom content, pass the `columns:` argument so the component knows how many grid columns to
@@ -3,10 +3,25 @@ import { Controller } from "@hotwired/stimulus";
3
3
  export default class TableRowPreview extends Controller {
4
4
  connect() {
5
5
  this.items = JSON.parse(this.element.dataset.items || '{}');
6
+ this.attachSwipeGesture();
7
+ }
8
+
9
+ disconnect() {
10
+ this.detachSwipeGesture();
6
11
  }
7
12
 
8
13
  toggle() {
9
- const rollUp = document.getElementById("roll-up");
14
+ const rollUp = this.rollUpElement();
15
+ if (!rollUp) return;
16
+
17
+ rollUp.classList.remove("animate-roll-down");
18
+ rollUp.classList.add("animate-roll-up");
19
+
20
+ // Show pre-rendered preview content that is hidden by default.
21
+ if (Object.keys(this.items).length === 0) {
22
+ rollUp.classList.remove("hidden");
23
+ return;
24
+ }
10
25
 
11
26
  const existingTable = rollUp.querySelector(".div-table");
12
27
  if (existingTable) {
@@ -18,8 +33,6 @@ export default class TableRowPreview extends Controller {
18
33
  existingTitle.remove();
19
34
  }
20
35
 
21
- if (Object.keys(this.items).length === 0) return;
22
-
23
36
  const titleText = document.createElement("h3");
24
37
 
25
38
  titleText.classList.add("text-xl");
@@ -33,12 +46,93 @@ export default class TableRowPreview extends Controller {
33
46
  rollUp.insertAdjacentElement('afterbegin', table);
34
47
  rollUp.insertAdjacentElement('afterbegin', titleText);
35
48
 
36
- rollUp.classList.toggle("hidden");
49
+ rollUp.classList.remove("hidden");
37
50
  }
38
51
 
39
52
  close() {
40
- const rollUp = document.getElementById("roll-up");
41
- rollUp.classList.add("hidden");
53
+ const rollUp = this.rollUpElement();
54
+ if (!rollUp) return;
55
+
56
+ this.resetDragStyles(rollUp);
57
+ rollUp.classList.remove("animate-roll-up");
58
+ rollUp.classList.add("animate-roll-down");
59
+
60
+ rollUp.addEventListener("animationend", () => {
61
+ rollUp.classList.add("hidden");
62
+ rollUp.classList.remove("animate-roll-down");
63
+ rollUp.classList.add("animate-roll-up");
64
+ }, { once: true });
65
+ }
66
+
67
+ rollUpElement() {
68
+ if (this.element.id === "roll-up") return this.element;
69
+
70
+ return this.element.previousElementSibling || document.getElementById("roll-up");
71
+ }
72
+
73
+ attachSwipeGesture() {
74
+ if (this.element.id !== "roll-up") return;
75
+
76
+ this.startY = null;
77
+ this.startX = null;
78
+ this.currentDeltaY = 0;
79
+
80
+ this.onTouchStart = (event) => {
81
+ if (this.element.classList.contains("hidden")) return;
82
+ if (event.touches.length !== 1) return;
83
+
84
+ this.startY = event.touches[0].clientY;
85
+ this.startX = event.touches[0].clientX;
86
+ this.currentDeltaY = 0;
87
+ this.element.style.transition = "none";
88
+ };
89
+
90
+ this.onTouchMove = (event) => {
91
+ if (this.startY === null || event.touches.length !== 1) return;
92
+
93
+ const deltaY = event.touches[0].clientY - this.startY;
94
+ const deltaX = Math.abs(event.touches[0].clientX - this.startX);
95
+ if (deltaY <= 0 || deltaY <= deltaX) return;
96
+
97
+ this.currentDeltaY = deltaY;
98
+ this.element.style.transform = `translateY(${deltaY}px)`;
99
+ event.preventDefault();
100
+ };
101
+
102
+ this.onTouchEnd = () => {
103
+ if (this.startY === null) return;
104
+
105
+ const shouldClose = this.currentDeltaY > 80;
106
+ this.startY = null;
107
+ this.startX = null;
108
+
109
+ if (shouldClose) {
110
+ this.close();
111
+ return;
112
+ }
113
+
114
+ this.resetDragStyles(this.element);
115
+ };
116
+
117
+ this.element.addEventListener("touchstart", this.onTouchStart, { passive: true });
118
+ this.element.addEventListener("touchmove", this.onTouchMove, { passive: false });
119
+ this.element.addEventListener("touchend", this.onTouchEnd);
120
+ this.element.addEventListener("touchcancel", this.onTouchEnd);
121
+ }
122
+
123
+ detachSwipeGesture() {
124
+ if (this.element.id !== "roll-up") return;
125
+ if (!this.onTouchStart) return;
126
+
127
+ this.element.removeEventListener("touchstart", this.onTouchStart);
128
+ this.element.removeEventListener("touchmove", this.onTouchMove);
129
+ this.element.removeEventListener("touchend", this.onTouchEnd);
130
+ this.element.removeEventListener("touchcancel", this.onTouchEnd);
131
+ }
132
+
133
+ resetDragStyles(element) {
134
+ element.style.transition = "";
135
+ element.style.transform = "";
42
136
  }
43
137
 
44
138
  createTable(items) {
@@ -19,7 +19,7 @@ class TailwindComponent < Tramway::BaseComponent
19
19
  file_button: 'text-sm px-3 py-1',
20
20
  submit_button: 'text-sm px-3 py-1',
21
21
  tramway_select_input: 'text-sm px-2 py-1 h-10',
22
- checkbox_input: 'h-4 w-4'
22
+ checkbox_input: 'min-h-4 min-w-4'
23
23
  },
24
24
  medium: {
25
25
  text_input: 'text-base px-3 py-2',
@@ -27,7 +27,7 @@ class TailwindComponent < Tramway::BaseComponent
27
27
  file_button: 'text-base px-4 py-2',
28
28
  submit_button: 'text-base px-4 py-2',
29
29
  tramway_select_input: 'text-base px-2 py-1 h-12',
30
- checkbox_input: 'h-5 w-5'
30
+ checkbox_input: 'min-h-5 min-w-5'
31
31
  },
32
32
  large: {
33
33
  text_input: 'text-xl px-4 py-3',
@@ -35,7 +35,7 @@ class TailwindComponent < Tramway::BaseComponent
35
35
  file_button: 'text-xl px-5 py-3',
36
36
  submit_button: 'text-xl px-5 py-3',
37
37
  tramway_select_input: 'text-xl px-3 py-2 h-15',
38
- checkbox_input: 'h-6 w-6'
38
+ checkbox_input: 'min-h-6 min-w-6'
39
39
  }
40
40
  }.freeze
41
41
 
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for, class: 'mb-0') do
3
+ = component('tramway/form/label', for: @for, class: 'mb-2') do
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))
@@ -36,42 +36,91 @@
36
36
 
37
37
  :javascript
38
38
  (() => {
39
- const button = document.getElementById('mobile-menu-button');
40
- const closeButton = document.getElementById('mobile-menu-close-button');
41
- const menu = document.getElementById('mobile-menu');
42
39
  const root = document.documentElement;
40
+ const MENU_HIDDEN_CLASS = 'hidden';
41
+ const MENU_OFFSCREEN_CLASS = '-translate-x-full';
42
+ const ROOT_LOCK_CLASS = 'overflow-hidden';
43
43
 
44
- if (!button || !closeButton || !menu) {
45
- return;
46
- }
44
+ const closeMenu = (menu, { hideImmediately = false } = {}) => {
45
+ if (!menu) {
46
+ root.classList.remove(ROOT_LOCK_CLASS);
47
+ return;
48
+ }
47
49
 
48
- if (button.dataset.navbarBound === 'true') {
49
- return;
50
- }
50
+ menu.classList.add(MENU_OFFSCREEN_CLASS);
51
+ root.classList.remove(ROOT_LOCK_CLASS);
51
52
 
52
- button.dataset.navbarBound = 'true';
53
-
54
- const toggleMenu = () => {
55
- const isHiddenNow = menu.classList.contains('hidden');
56
-
57
- if (isHiddenNow) {
58
- menu.classList.remove('hidden');
59
- requestAnimationFrame(() => {
60
- menu.classList.remove('-translate-x-full');
61
- root.classList.add('overflow-hidden');
62
- });
63
- } else {
64
- menu.classList.add('-translate-x-full');
65
- root.classList.remove('overflow-hidden');
66
-
67
- menu.addEventListener(
68
- 'transitionend',
69
- () => menu.classList.add('hidden'),
70
- { once: true }
71
- );
53
+ if (hideImmediately) {
54
+ menu.classList.add(MENU_HIDDEN_CLASS);
55
+ return;
72
56
  }
57
+
58
+ menu.addEventListener(
59
+ 'transitionend',
60
+ () => menu.classList.add(MENU_HIDDEN_CLASS),
61
+ { once: true }
62
+ );
73
63
  };
74
64
 
75
- button.addEventListener('click', toggleMenu);
76
- closeButton.addEventListener('click', toggleMenu);
65
+ const openMenu = (menu) => {
66
+ if (!menu) return;
67
+
68
+ menu.classList.remove(MENU_HIDDEN_CLASS);
69
+ requestAnimationFrame(() => {
70
+ menu.classList.remove(MENU_OFFSCREEN_CLASS);
71
+ root.classList.add(ROOT_LOCK_CLASS);
72
+ });
73
+ };
74
+
75
+ const bindNavbar = () => {
76
+ const button = document.getElementById('mobile-menu-button');
77
+ const closeButton = document.getElementById('mobile-menu-close-button');
78
+ const menu = document.getElementById('mobile-menu');
79
+
80
+ if (!button || !closeButton || !menu || button.dataset.navbarBound === 'true') {
81
+ return;
82
+ }
83
+
84
+ button.dataset.navbarBound = 'true';
85
+
86
+ const toggleMenu = () => {
87
+ if (menu.classList.contains(MENU_HIDDEN_CLASS)) {
88
+ openMenu(menu);
89
+ return;
90
+ }
91
+
92
+ closeMenu(menu);
93
+ };
94
+
95
+ button.addEventListener('click', toggleMenu);
96
+ closeButton.addEventListener('click', () => closeMenu(menu));
97
+ menu.querySelectorAll('a').forEach((link) => {
98
+ link.addEventListener('click', () => closeMenu(menu, { hideImmediately: true }));
99
+ });
100
+ };
101
+
102
+ if (window.__tramwayNavbarGlobalBound !== true) {
103
+ window.__tramwayNavbarGlobalBound = true;
104
+
105
+ document.addEventListener('turbo:before-cache', () => {
106
+ closeMenu(document.getElementById('mobile-menu'), { hideImmediately: true });
107
+ });
108
+
109
+ document.addEventListener('turbo:load', () => {
110
+ const menu = document.getElementById('mobile-menu');
111
+ bindNavbar();
112
+
113
+ if (!menu || menu.classList.contains(MENU_HIDDEN_CLASS)) {
114
+ root.classList.remove(ROOT_LOCK_CLASS);
115
+ }
116
+ });
117
+
118
+ window.addEventListener('resize', () => {
119
+ if (window.innerWidth >= 640) {
120
+ closeMenu(document.getElementById('mobile-menu'), { hideImmediately: true });
121
+ }
122
+ });
123
+ }
124
+
125
+ bindNavbar();
77
126
  })();
@@ -13,6 +13,22 @@
13
13
  animation: roll-up 0.5s ease-in-out;
14
14
  }
15
15
 
16
- #roll-up{ class: preview_classes }
17
- %button{ class: close_button_classes, "data-action" => "click->preview#close", "data-controller" => "preview" }
16
+ @keyframes roll-down {
17
+ 0% {
18
+ transform: translateY(0%);
19
+ }
20
+
21
+ 100% {
22
+ transform: translateY(100%);
23
+ }
24
+ }
25
+
26
+ .animate-roll-down {
27
+ animation: roll-down 0.5s ease-in-out;
28
+ }
29
+
30
+ #roll-up{ class: preview_classes, data: { controller: 'table-row-preview' } }
31
+ %button{ class: close_button_classes, "data-action" => "click->table-row-preview#close" }
18
32
  &#x2715;
33
+
34
+ = content
@@ -7,7 +7,8 @@ module Tramway
7
7
  class PreviewComponent < Tramway::BaseComponent
8
8
  def preview_classes
9
9
  theme_classes(
10
- classic: 'fixed hidden inset-x-0 bottom-0 shadow-lg z-50 bg-gray-100 animate-roll-up h-1/2'
10
+ classic: 'fixed hidden md:hidden bottom-0 left-0 right-0 w-screen shadow-lg z-50 bg-gray-900 ' \
11
+ 'animate-roll-up h-1/2 pt-4'
11
12
  )
12
13
  end
13
14
 
@@ -5,11 +5,18 @@
5
5
  = value
6
6
 
7
7
  - else
8
- - cells = Nokogiri::HTML.fragment(content).xpath('./*[@class and contains(concat(" ", normalize-space(@class), " "), " div-table-cell ")]')
8
+ - cells = visible_cells_from(content)
9
9
 
10
10
  - if href.present?
11
11
  = tag.a href:, class: [desktop_row_classes(cells.count), link_row_classes].join(' '), **default_attributes do
12
12
  = content
13
13
  - else
14
- = tag.div class: desktop_row_classes(cells.count), **default_attributes do
14
+ - if preview
15
+ = component 'tramway/table/row/preview' do
16
+ - cells.each_with_index do |cell, index|
17
+ - next if index.zero?
18
+
19
+ = cell.to_s.html_safe
20
+
21
+ = tag.div class: desktop_row_classes(cells.count), data: { action: "click->table-row-preview#toggle", controller: "table-row-preview" }, **default_attributes do
15
22
  = content
@@ -6,6 +6,7 @@ module Tramway
6
6
  class RowComponent < Tramway::BaseComponent
7
7
  option :cells, optional: true, default: -> { [] }
8
8
  option :href, optional: true
9
+ option :preview, optional: true, default: -> { true }
9
10
  option :options, optional: true, default: -> { {} }
10
11
 
11
12
  def default_attributes
@@ -57,6 +58,22 @@ module Tramway
57
58
 
58
59
  private
59
60
 
61
+ def visible_cells_from(content)
62
+ fragment = Nokogiri::HTML.fragment(content)
63
+ parsed_cells = fragment.xpath(
64
+ './*[@class and contains(concat(" ", normalize-space(@class), " "), " div-table-cell ")]'
65
+ )
66
+
67
+ parsed_cells.each { |cell| remove_hidden_class!(cell) }
68
+ end
69
+
70
+ def remove_hidden_class!(node)
71
+ classes = node['class'].to_s.split
72
+ return if classes.empty?
73
+
74
+ node['class'] = classes.reject { |class_name| class_name == 'hidden' }.join(' ')
75
+ end
76
+
60
77
  def ensure_view_context_accessor
61
78
  return if view_context.respond_to?(:tramway_inside_cell=)
62
79
 
@@ -1,5 +1,3 @@
1
- = helpers.component 'tramway/table/row/preview'
2
-
3
1
  - width_class = options[:class]&.include?('w-') ? '' : 'w-full'
4
2
 
5
3
  %div{ class: "#{table_classes} #{options[:class]} #{width_class}".strip, **options.except(:class) }
@@ -118,6 +118,17 @@ module.exports = {
118
118
  'first:block',
119
119
  'rounded-t-xl',
120
120
 
121
+ // === Table row preview panel ===
122
+ 'bottom-0',
123
+ 'left-0',
124
+ 'right-0',
125
+ 'h-1/2',
126
+ 'bg-gray-100',
127
+ 'animate-roll-up',
128
+ 'hover:text-gray-700',
129
+ 'pt-4',
130
+ 'md:hidden',
131
+
121
132
  // === Title Component ===
122
133
  'md:text-4xl',
123
134
 
@@ -372,12 +383,12 @@ module.exports = {
372
383
  'h-15',
373
384
  'h-12',
374
385
  'h-10',
375
- 'w-2',
376
- 'h-2',
377
- 'w-4',
378
- 'h-4',
379
- 'w-6',
380
- 'h-6',
386
+ 'min-w-2',
387
+ 'min-h-2',
388
+ 'min-w-4',
389
+ 'min-h-4',
390
+ 'min-w-6',
391
+ 'min-h-6',
381
392
  'px-2',
382
393
  'py-1',
383
394
  'flex-nowrap',
data/docs/AGENTS.md CHANGED
@@ -149,6 +149,9 @@ Use Tramway Flash for user notifications.
149
149
  ### Rule 5
150
150
  Use Tramway Table for tabular data display.
151
151
 
152
+ `tramway_row` supports `href:` for clickable rows and `preview:` for mobile row preview behavior. Keep `preview: true` as the
153
+ default unless the request explicitly needs preview disabled.
154
+
152
155
  ### Rule 6
153
156
  Use Tramway Button for buttons. Always add a color of the button via `color:` or `type:` argument. `color:` argument support directs colors only: red, yellow, blue, etc. `type:` argument supports only lantern colors: will, hope, rage, etc.
154
157
 
@@ -191,6 +194,69 @@ For live updates to a rendered `tramway_chat`, use `tramway_chat_append_message(
191
194
  This method is included in all controllers and ActiveRecord models. `message_type` must be `:sent` or `:received`, otherwise
192
195
  it raises `ArgumentError`. `chat_id` must match the stream id used in `tramway_chat`.
193
196
 
197
+ `message_form` object must be a `Tramway::BaseForm` or an inherited class object. It must contain `text` attribute.
198
+ `send_message_path` must be a `POST` route that receives data on sending messsage submition.
199
+
200
+ Here an example of usage:
201
+
202
+ *app/views/chats/show.html.haml*
203
+ ```haml
204
+ = tramway_chat chat_id: @chat.id,
205
+ messages: @chat.messages_for_chat,
206
+ message_form: @message_form,
207
+ send_message_path: chats_messages_path
208
+ ```
209
+
210
+ *app/decorators/chat_decorator.rb*
211
+ ```ruby
212
+ class ChatDecorator < Tramway::BaseDecorator
213
+ def messages_for_chat
214
+ object.messages.map do |message|
215
+ {
216
+ id: message.id,
217
+ type: :sent, # or received
218
+ text: message.text,
219
+ sent_at: message.created_at,
220
+ }
221
+ end
222
+ end
223
+ end
224
+ ```
225
+
226
+ *app/controllers/chats_controller.rb*
227
+ ```ruby
228
+ def show
229
+ @chat = tramway_decorate Chat.find params[:id]
230
+ @message_form = tramway_form chat.messages.build
231
+ end
232
+ ```
233
+
234
+ **app/forms/message_form.rb**
235
+ ```ruby
236
+ class Chats::MessageForm < Tramway::BaseForm
237
+ properties :text, :chat_id
238
+ end
239
+ ```
240
+
241
+ **config/routes.rb**
242
+ ```ruby
243
+ resources :messages, only: :create
244
+ ```
245
+
246
+ **app/controllers/messages_controller.rb**
247
+ ```ruby
248
+ def create
249
+ @message = tramway_form chat.creator.messages.build(chat:)
250
+
251
+ if @message.submit params[:message]
252
+ tramway_chat_append_message chat_id: @message.object.chat.id,
253
+ type: :sent,
254
+ text: @message.object.text,
255
+ sent_at: @message.object.created_at
256
+ end
257
+ end
258
+ ```
259
+
194
260
  ### Rule 9
195
261
  If page `create` or `update` is configured for an entity, use Tramway Form pattern for forms. Visible fields are configured via `form_fields` method.
196
262
 
@@ -505,6 +571,36 @@ and in a controller
505
571
  end
506
572
  ```
507
573
 
574
+ Use dry-monads while call these services.
575
+
576
+ Instead of this
577
+
578
+ ```ruby
579
+ result = SomeService.call(args)
580
+
581
+ case result
582
+ when :success
583
+ # code
584
+ when :failure
585
+ # code
586
+ end
587
+ ```
588
+
589
+ Do this
590
+
591
+ ```ruby
592
+ case SomeService.call(args)
593
+ in Success(result)
594
+ # use result
595
+ in Success(another_result)
596
+ # use another result
597
+ in Failure(reason_or_error)
598
+ # do stuff
599
+ end
600
+ ```
601
+
602
+ Success and Failure must describe all possible results of calling certain service.
603
+
508
604
  ### Rule 27
509
605
  Don't create scopes for enumerated values, use `scope: :shallow` of the enumerize gem.
510
606
 
@@ -52,10 +52,36 @@ module Tramway
52
52
  @importmap_path ||= File.join(destination_root, 'config/importmap.rb')
53
53
  end
54
54
 
55
+ def controllers_index_path
56
+ @controllers_index_path ||= File.join(destination_root, 'app/javascript/controllers/index.js')
57
+ end
58
+
55
59
  def importmap_tramway_select_pin
56
60
  'pin "@tramway/tramway-select", to: "tramway/tramway-select_controller.js"'
57
61
  end
58
62
 
63
+ def importmap_table_row_preview_pin
64
+ 'pin "@tramway/table-row-preview", to: "tramway/table_row_preview_controller.js"'
65
+ end
66
+
67
+ def importmap_tramway_pins
68
+ [importmap_tramway_select_pin, importmap_table_row_preview_pin]
69
+ end
70
+
71
+ def stimulus_controller_imports
72
+ [
73
+ 'import { TramwaySelect } from "@tramway/tramway-select"',
74
+ 'import { TableRowPreview } from "@tramway/table-row-preview"'
75
+ ]
76
+ end
77
+
78
+ def stimulus_controller_registrations
79
+ [
80
+ "application.register('tramway-select', TramwaySelect)",
81
+ "application.register('table-row-preview', TableRowPreview)"
82
+ ]
83
+ end
84
+
59
85
  def agents_file_path
60
86
  @agents_file_path ||= File.join(destination_root, 'AGENTS.md')
61
87
  end
@@ -159,6 +185,43 @@ module Tramway
159
185
  agents_template
160
186
  )
161
187
  end
188
+
189
+ # rubocop:disable Metrics/MethodLength
190
+ def append_missing_imports(content)
191
+ missing_imports = stimulus_controller_imports.reject { |line| content.include?(line) }
192
+ return content if missing_imports.empty?
193
+
194
+ import_lines = content.each_line.with_index.filter_map do |line, index|
195
+ index if line.lstrip.start_with?('import ')
196
+ end
197
+ insertion = "#{missing_imports.join("\n")}\n"
198
+ updated = content.dup
199
+
200
+ if import_lines.any?
201
+ insertion_index = updated.lines[0..import_lines.max].join.length
202
+ updated.insert(insertion_index, insertion)
203
+ else
204
+ updated.prepend(insertion)
205
+ end
206
+
207
+ updated
208
+ end
209
+ # rubocop:enable Metrics/MethodLength
210
+
211
+ def append_missing_registrations(content)
212
+ missing_registrations = stimulus_controller_registrations.reject { |line| content.include?(line) }
213
+ return content if missing_registrations.empty?
214
+
215
+ insertion = "#{missing_registrations.join("\n")}\n"
216
+ export_match = /^(?:export\s*\{[^}]+\}\s*;?\s*)$/m.match(content)
217
+
218
+ return content.dup.insert(export_match.begin(0), insertion) if export_match
219
+
220
+ updated = content.dup
221
+ updated << "\n" unless updated.empty? || updated.end_with?("\n")
222
+ updated << insertion
223
+ updated
224
+ end
162
225
  end
163
226
  # rubocop:enable Metrics/ModuleLength
164
227
 
@@ -242,13 +305,25 @@ module Tramway
242
305
  return unless File.exist?(importmap_path)
243
306
 
244
307
  content = File.read(importmap_path)
245
- return if content.include?(importmap_tramway_select_pin)
308
+ missing_pins = importmap_tramway_pins.reject { |pin| content.include?(pin) }
309
+ return if missing_pins.empty?
246
310
 
247
311
  File.open(importmap_path, 'a') do |file|
248
312
  file.write("\n") unless content.empty? || content.end_with?("\n")
249
- file.write("#{importmap_tramway_select_pin}\n")
313
+ file.write("#{missing_pins.join("\n")}\n")
250
314
  end
251
315
  end
316
+
317
+ def ensure_stimulus_controller_registration
318
+ return unless File.exist?(controllers_index_path)
319
+
320
+ content = File.read(controllers_index_path)
321
+ updated = append_missing_imports(content)
322
+ updated = append_missing_registrations(updated)
323
+ return if updated == content
324
+
325
+ File.write(controllers_index_path, updated)
326
+ end
252
327
  end
253
328
  end
254
329
  end
@@ -34,6 +34,7 @@ module Tramway
34
34
  component 'tramway/table/row',
35
35
  cells: options.delete(:cells),
36
36
  href: options.delete(:href),
37
+ preview: options.delete(:preview),
37
38
  options:,
38
39
  &
39
40
  end
@@ -42,9 +42,13 @@ module Tramway
42
42
 
43
43
  def field_options(field_data)
44
44
  if field_data.is_a?(Hash)
45
- value = field_data[:value]&.call(object)
45
+ if field_data[:value].present?
46
+ value = field_data[:value]&.call(object)
46
47
 
47
- field_data.merge(value:).except(:type)
48
+ field_data.merge(value:)
49
+ else
50
+ field_data
51
+ end.except(:type)
48
52
  else
49
53
  {}
50
54
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tramway
4
- VERSION = '3.0.2'
4
+ VERSION = '3.0.3'
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.0.2
4
+ version: 3.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - kalashnikovisme
@@ -320,7 +320,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
320
320
  - !ruby/object:Gem::Version
321
321
  version: '0'
322
322
  requirements: []
323
- rubygems_version: 4.0.3
323
+ rubygems_version: 4.0.6
324
324
  specification_version: 4
325
325
  summary: Tramway Rails Engine
326
326
  test_files: []