tramway 2.3 → 2.3.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: c7533942aab1b11f547036f0ff9e819c9162d30c3cc11221e578f175d9b3215b
4
- data.tar.gz: 52f17dda1d18d9c1c93f91e3a5a372a7a0b38957e220bbdc0fc25f7d6ff3c5d1
3
+ metadata.gz: 0062ba6e33495a79ef0d2dc0fcca348e7de9ed5a892855d800bc7de45022205d
4
+ data.tar.gz: 350731c7514a5b6f3d513635c2397f65a52f81cb50a05ff0e198af191a09b75d
5
5
  SHA512:
6
- metadata.gz: b7e1e85e67d07628c99411d29945c7cc102dce00278abc83c88999e8880ef11082a3a304ba792f6daca306c8ef65cdffbeaabfcabf523016ed3fb1d9c42a35cc
7
- data.tar.gz: dba51b7a03411bac7f6cbe4b7b1e4049a13f9863e3a943ecc0fce58cae17471b29c6b14cd24d4aef6f4e7a41d44afe06bccd8d6fd6e2801d72c0550bb205a92d
6
+ metadata.gz: 4c5ad624bc5aa06c742ac6a02cbaea4accfe21028ec422f5818514a3bb87b854f06e830986f35d767a2fe82f06f0d3ab433e615651522e8ab332d46925e54c58
7
+ data.tar.gz: ffd97ea24ee55e590368a3a34beafcb440010358a65db27d0e231629225bcd05f0d0f22987c38f6dc7ebf97193ebc6f26ba76628280b2e1e92169f1b20dd69fa
data/README.md CHANGED
@@ -884,9 +884,28 @@ message fields like `text`, `data`, or `sent_at` are forwarded to `tramway/chats
884
884
  send_message_path: chat_messages_path %>
885
885
  ```
886
886
 
887
+
887
888
  If you do not want to render the message form, pass `message_form: nil`. When the form is present, `send_message_path` is
888
889
  required and the helper will generate the correct POST form.
889
890
 
891
+ To append messages to an already-rendered `tramway_chat` stream, use `tramway_chat_append_message`.
892
+ The method is mixed into controllers and ActiveRecord models by Tramway and expects:
893
+ - `chat_id:` — the same value used in `tramway_chat chat_id:`
894
+ - `message_type:` — only `:sent` or `:received` (raises `ArgumentError` otherwise)
895
+ - `text:` — message content
896
+ - `sent_at:` — message timestamp
897
+
898
+ ```ruby
899
+ tramway_chat_append_message(
900
+ chat_id: 'support-chat',
901
+ message_type: :received,
902
+ text: 'We got your request',
903
+ sent_at: Time.current
904
+ )
905
+ ```
906
+
907
+ It broadcasts with `target: 'messages'` and renders the `tramway/chats/message` partial so the message appears in the live chat.
908
+
890
909
  ### Tramway Table Component
891
910
 
892
911
  Tramway provides a responsive, tailwind-styled table with light and dark themes. Use the `tramway_table`, `tramway_row`, and
@@ -1,28 +1,77 @@
1
1
  = helpers.turbo_stream_from chat_id, 'messages'
2
2
 
3
- .flex-1.min-h-0.flex.flex-col
4
- #chat.mx-auto.flex.flex-1.flex-col.w-full.rounded-2xl.border.shadow-sm.ring-gray-700.md:p-4.border-gray-100.min-h-0.overflow-hidden{ data: { controller: 'chat' } }
5
- .flex-1.min-h-0.overflow-y-auto.p-2.md:p-6.space-y-2.md:space-y-4.md:rounded-xl.rounded-t-xl#messages{ data: { chat_target: 'messages' }, class: 'text-gray-100 bg-gray-800/60' }
6
- - messages.each do |message|
7
- = component "tramway/chats/message", **message
8
- - if disabled?
9
- = inline_svg 'icons/dots.svg', class: 'w-8 h-8 text-gray-500 mx-auto my-4'
10
-
11
- - if message_form.present?
12
- .shrink-0.border-gray-200.md:pt-2.border-gray-700
13
- = form_for message_form, url: send_message_path, method: :post, html: { class: 'flex items-center md:gap-1' } do |f|
14
- - waiting_placeholder = t('chat.placeholders.waiting')
15
- - typing_placeholder = t('chat.placeholders.type')
16
- = f.text_field :text,
17
- class: 'flex-1 md:rounded-full rounded-bl-2xl md:border border-gray-300 px-4 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 disabled:cursor-not-allowed disabled:bg-gray-100 border-gray-600 bg-gray-800 text-white focus:border-blue-400 focus:ring-blue-500/30',
18
- placeholder: send_messages_enabled ? typing_placeholder : waiting_placeholder,
19
- data: { chat_target: 'input' },
20
- disabled: !send_messages_enabled
21
-
22
- = f.hidden_field :chat_id, value: chat_id
23
-
24
- - options.each do |(key, value)|
25
- = f.hidden_field key, value: value
26
-
27
- = f.submit '🡩',
28
- class: 'md:rounded-full bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 bg-blue-500 hover:bg-blue-400 cursor-pointer'
3
+ #chat.flex.flex-1.h-full.w-full.min-h-0.min-w-0.flex-col
4
+ #messages.flex.flex-col.flex-1.min-h-0.overflow-y-auto.p-2.md:p-6.space-y-2.md:space-y-4.md:rounded-xl.rounded-t-xl{ class: 'text-gray-100 bg-gray-800/60' }
5
+ - messages.each do |message|
6
+ = component "tramway/chats/message", **message
7
+ - if disabled?
8
+ = inline_svg 'icons/dots.svg', class: 'w-8 h-8 text-gray-500 mx-auto my-4'
9
+
10
+ - if message_form.present?
11
+ .shrink-0.border-gray-200.md:pt-2.border-gray-700
12
+ = form_for message_form, url: send_message_path, as: :message, method: :post, html: { class: 'flex items-center md:gap-1' } do |f|
13
+ - waiting_placeholder = t('tramway.chat.placeholders.waiting')
14
+ - typing_placeholder = t('tramway.chat.placeholders.type')
15
+ = f.text_field :text,
16
+ class: 'flex-1 md:rounded-full rounded-bl-2xl md:border border-gray-300 px-4 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 disabled:cursor-not-allowed disabled:bg-gray-100 border-gray-600 bg-gray-800 text-white focus:border-blue-400 focus:ring-blue-500/30',
17
+ placeholder: send_messages_enabled ? typing_placeholder : waiting_placeholder,
18
+ disabled: !send_messages_enabled
19
+
20
+ = f.hidden_field :chat_id, value: chat_id
21
+
22
+ - options.each do |(key, value)|
23
+ = f.hidden_field key, value: value
24
+
25
+ = f.submit '🡩',
26
+ class: 'md:rounded-full bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 bg-blue-500 hover:bg-blue-400 cursor-pointer'
27
+
28
+ :javascript
29
+ (() => {
30
+ const setupChatScroll = () => {
31
+ const messages = document.getElementById('messages');
32
+ if (!messages || messages.dataset.chatScrollInitialized === 'true') return;
33
+
34
+ const scrollToBottom = () => {
35
+ messages.scrollTop = messages.scrollHeight;
36
+ };
37
+
38
+ scrollToBottom();
39
+
40
+ const observer = new MutationObserver(scrollToBottom);
41
+ observer.observe(messages, { childList: true });
42
+
43
+ messages.dataset.chatScrollInitialized = 'true';
44
+ };
45
+
46
+ const setupChatInputClear = () => {
47
+ const forms = document.querySelectorAll('form[action][method]');
48
+
49
+ forms.forEach((form) => {
50
+ if (form.dataset.chatInputClearInitialized === 'true') return;
51
+
52
+ const textInput = form.querySelector('input[name="message[text]"]');
53
+ if (!textInput) return;
54
+
55
+ form.addEventListener('turbo:submit-end', (event) => {
56
+ if (event.detail.success) {
57
+ textInput.value = '';
58
+ }
59
+ });
60
+
61
+ form.dataset.chatInputClearInitialized = 'true';
62
+ });
63
+ };
64
+
65
+ if (document.readyState === 'loading') {
66
+ document.addEventListener('DOMContentLoaded', () => {
67
+ setupChatScroll();
68
+ setupChatInputClear();
69
+ }, { once: true });
70
+ } else {
71
+ setupChatScroll();
72
+ setupChatInputClear();
73
+ }
74
+
75
+ document.addEventListener('turbo:load', setupChatScroll);
76
+ document.addEventListener('turbo:load', setupChatInputClear);
77
+ })();
@@ -1,3 +1,2 @@
1
- - lines&.each do |line|
2
- %p{ class: text_class }
3
- = line
1
+ %div
2
+ = rendered_html
@@ -1,27 +1,148 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'erb'
4
+
3
5
  module Tramway
4
6
  # Displays text with size-based utility classes.
5
7
  class NativeTextComponent < Tramway::BaseComponent
8
+ URL_REGEX = %r{https?://[^\s<]+}
9
+ MAX_URL_LENGTH = 41
10
+ HEADER_REGEX = /\A(#{Regexp.escape('#')}{1,6})\s+(.+)\z/
11
+ LIST_ITEM_REGEX = /\A[-*]\s+(.+)\z/
12
+ HEADER_CLASSES = {
13
+ 1 => 'text-4xl font-bold leading-tight mt-4 mb-2',
14
+ 2 => 'text-3xl font-bold leading-tight mt-4 mb-2',
15
+ 3 => 'text-2xl font-semibold leading-snug mt-3 mb-2',
16
+ 4 => 'text-xl font-semibold leading-snug mt-3 mb-2',
17
+ 5 => 'text-lg font-semibold leading-snug mt-2 mb-1',
18
+ 6 => 'text-base font-semibold leading-snug mt-2 mb-1'
19
+ }.freeze
20
+
6
21
  option :text
7
22
  option :size, optional: true, default: -> { :middle }
8
23
  option :klass, optional: true, default: -> { '' }
9
24
 
10
- def lines
11
- @lines ||= text&.split("\n")
25
+ def rendered_html
26
+ # Safe because this is an empty static string literal, not user-controlled content.
27
+ return ''.html_safe if text.blank?
28
+
29
+ helpers.safe_join(rendered_blocks)
12
30
  end
13
31
 
14
32
  def text_class
15
- case size
16
- when :small
17
- 'text-sm'
18
- when :large
19
- 'text-lg'
20
- when :middle
21
- 'text-base'
22
- else
23
- 'md:text-sm lg:text-base'
24
- end + " #{klass}"
33
+ { small: 'text-sm', large: 'text-lg', middle: 'text-base' }.fetch(size, 'md:text-sm lg:text-base') + " #{klass}"
34
+ end
35
+
36
+ private
37
+
38
+ # rubocop:disable Metrics/AbcSize
39
+ # rubocop:disable Metrics/MethodLength
40
+ def rendered_blocks
41
+ blocks = []
42
+ list_items = []
43
+
44
+ text.split("\n").each do |line|
45
+ stripped_line = line.strip
46
+
47
+ if stripped_line.blank?
48
+ flush_list_items(blocks, list_items)
49
+ next
50
+ end
51
+
52
+ header_match = stripped_line.match(HEADER_REGEX)
53
+ if header_match
54
+ flush_list_items(blocks, list_items)
55
+ blocks << helpers.content_tag(
56
+ "h#{header_match[1].length}",
57
+ render_inline_markdown(header_match[2]),
58
+ class: header_class(header_match[1].length)
59
+ )
60
+ next
61
+ end
62
+
63
+ list_match = stripped_line.match(LIST_ITEM_REGEX)
64
+ if list_match
65
+ list_items << helpers.content_tag(:li, render_inline_markdown(list_match[1]),
66
+ class: "#{text_class} marker:hidden")
67
+ next
68
+ end
69
+
70
+ flush_list_items(blocks, list_items)
71
+ blocks << helpers.content_tag(:p, render_inline_markdown(stripped_line), class: "#{text_class} my-1")
72
+ end
73
+
74
+ flush_list_items(blocks, list_items)
75
+ blocks
76
+ end
77
+ # rubocop:enable Metrics/AbcSize
78
+ # rubocop:enable Metrics/MethodLength
79
+
80
+ def flush_list_items(blocks, list_items)
81
+ return if list_items.empty?
82
+
83
+ blocks << helpers.content_tag(:ul, helpers.safe_join(list_items), class: "list-none pl-5 my-2 #{klass}")
84
+ list_items.clear
85
+ end
86
+
87
+ def header_class(level)
88
+ "#{HEADER_CLASSES.fetch(level)} #{klass}".strip
89
+ end
90
+
91
+ def render_inline_markdown(content)
92
+ escaped_content = ERB::Util.html_escape(content)
93
+ with_bold = escaped_content.gsub(/\*\*(.+?)\*\*/, '<strong>\\1</strong>')
94
+ with_italics = with_bold.gsub(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/, '<em>\\1</em>')
95
+ with_underscored_italics = with_italics.gsub(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/, '<em>\\1</em>')
96
+ # Safe because user input has already been escaped above and only controlled tags are introduced.
97
+ # rubocop:disable Rails/OutputSafety
98
+ linkified(with_underscored_italics).html_safe
99
+ end
100
+
101
+ # rubocop:disable Metrics/MethodLength
102
+ # rubocop:disable Metrics/AbcSize
103
+ def linkified(content)
104
+ fragments = []
105
+ current_index = 0
106
+
107
+ content.to_enum(:scan, URL_REGEX).map do
108
+ match = Regexp.last_match
109
+ matched_url = match[0]
110
+ url, trailing = strip_trailing_punctuation(matched_url)
111
+
112
+ # Safe because this fragment is sliced from `content`, which was already HTML-escaped.
113
+
114
+ fragments << content[current_index...match.begin(0)].html_safe # rubocop:disable Rails/OutputSafety
115
+ fragments << helpers.link_to(
116
+ shorten(url),
117
+ url,
118
+ target: '_blank',
119
+ rel: 'noopener noreferrer',
120
+ class: 'text-blue-400 hover:underline'
121
+ )
122
+ # Safe because `trailing` can only contain stripped URL punctuation (.,!?;:).
123
+ fragments << trailing.html_safe if trailing.present? # rubocop:disable Rails/OutputSafety
124
+ current_index = match.end(0)
125
+ end
126
+
127
+ # Safe because this tail fragment also comes from the already escaped `content` string.
128
+ fragments << content[current_index..].html_safe if current_index < content.length
129
+ # rubocop:enable Rails/OutputSafety
130
+
131
+ helpers.safe_join(fragments)
132
+ end
133
+ # rubocop:enable Metrics/AbcSize
134
+ # rubocop:enable Metrics/MethodLength
135
+
136
+ def strip_trailing_punctuation(url)
137
+ stripped_url = url.sub(/[.,!?;:]+\z/, '')
138
+ trailing = url.delete_prefix(stripped_url)
139
+ [stripped_url, trailing]
140
+ end
141
+
142
+ def shorten(url)
143
+ return url if url.length <= MAX_URL_LENGTH
144
+
145
+ "#{url[0...MAX_URL_LENGTH]}..."
25
146
  end
26
147
  end
27
148
  end
@@ -0,0 +1,4 @@
1
+ = component 'tramway/chats/message',
2
+ type:,
3
+ text:,
4
+ sent_at:
@@ -48,6 +48,7 @@ module.exports = {
48
48
  'hover:bg-blue-400',
49
49
  'text-gray-100',
50
50
  'text-gray-400',
51
+ 'text-blue-400',
51
52
  'placeholder:text-gray-400',
52
53
  'focus:border-blue-400',
53
54
  'focus:ring-blue-500/30',
@@ -85,6 +86,7 @@ module.exports = {
85
86
  'bg-blue-600',
86
87
  'bg-gray-800/60',
87
88
  'min-h-0',
89
+ 'min-w-0',
88
90
  'flex-1',
89
91
  'overflow-y-auto',
90
92
  'overflow-hidden',
@@ -102,6 +104,9 @@ module.exports = {
102
104
  'rounded-bl-2xl',
103
105
  'md:border',
104
106
  'md:gap-1',
107
+ 'hover:underline',
108
+ 'marker:hidden',
109
+ 'list-none',
105
110
 
106
111
  // === Custom table layout utilities ===
107
112
  'div-table',
data/docs/AGENTS.md CHANGED
@@ -132,6 +132,10 @@ When you need chat UI, use the `tramway_chat` helper. Pass `chat_id`, `messages`
132
132
  Each message must include `:id` and a `:type` of `:sent` or `:received`, and other keys (like `:text`, `:data`, `:sent_at`)
133
133
  are forwarded to `tramway/chats/message_component`. Use `message_form: nil` when you only need read-only chat rendering.
134
134
 
135
+ For live updates to a rendered `tramway_chat`, use `tramway_chat_append_message(chat_id:, message_type:, text:, sent_at:)`.
136
+ This method is included in all controllers and ActiveRecord models. `message_type` must be `:sent` or `:received`, otherwise
137
+ it raises `ArgumentError`. `chat_id` must match the stream id used in `tramway_chat`.
138
+
135
139
  ### Rule 9
136
140
  If page `create` or `update` is configured for an entity, use Tramway Form pattern for forms. Visible fields are configured via `form_fields` method.
137
141
 
@@ -448,6 +452,18 @@ scope :for_role, -> (role) { where role: role }
448
452
  ### Rule 28
449
453
  In case, you need to make a one link in tramway_table on each row. Use `tramway_row href: your_link` instead of putting the like inside a cell.
450
454
 
455
+ ### Rule 29
456
+ Always use tramway decorated objects in views.
457
+
458
+ ### Rule 30
459
+ For Tailwind classes with `/`, `[`, `]` characters use `{ class: 'here is the complicated class' }` in HAML.
460
+
461
+ ### Rule 31
462
+ Always `tramway_decorate` and `tramway_form` for creating these types of objects. Don't use decorator and form classes for this.
463
+
464
+ ### Rule 32
465
+ In Tramway Decorators, use `delegate_attributes` method instead of `delegate :something, to: :object`
466
+
451
467
  ## Controller Patterns
452
468
 
453
469
  - Keep actions short and explicit with guard clauses.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ module Chats
5
+ # This module provides a method to broadcast new chat messages to a Tramway Chat
6
+ module Broadcast
7
+ MESSAGE_TYPES = %i[sent received].freeze
8
+
9
+ def tramway_chat_append_message(chat_id:, type:, text:, sent_at:)
10
+ raise ArgumentError, 'message_type must be :sent or :received' unless MESSAGE_TYPES.include?(type.to_sym)
11
+
12
+ Turbo::StreamsChannel.broadcast_append_to [chat_id, 'messages'],
13
+ target: 'messages',
14
+ partial: 'tramway/chats/message',
15
+ locals: { type:, text:, sent_at: }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -12,6 +12,7 @@ module Tramway
12
12
  load_decorator_helper
13
13
  load_form_helper
14
14
  load_routes_helper
15
+ load_chats_broadcast
15
16
  configure_pagination if Tramway.config.pagination[:enabled]
16
17
  end
17
18
 
@@ -67,6 +68,16 @@ module Tramway
67
68
  end
68
69
  end
69
70
 
71
+ def load_chats_broadcast
72
+ ActiveSupport.on_load(:action_controller) do |loaded_class|
73
+ loaded_class.include Tramway::Chats::Broadcast
74
+ end
75
+
76
+ ActiveSupport.on_load(:active_record) do |loaded_class|
77
+ loaded_class.include Tramway::Chats::Broadcast
78
+ end
79
+ end
80
+
70
81
  def configure_pagination
71
82
  ActiveSupport.on_load(:action_controller) do
72
83
  # Detecting tramway views path
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tramway
4
- VERSION = '2.3'
4
+ VERSION = '2.3.1'
5
5
  end
data/lib/tramway.rb CHANGED
@@ -6,6 +6,7 @@ require 'tramway/engine'
6
6
  require 'tramway/base_decorator'
7
7
  require 'tramway/base_form'
8
8
  require 'tramway/config'
9
+ require 'tramway/chats/broadcast'
9
10
  require 'tramway/views/form_builder'
10
11
  require 'view_component/compiler'
11
12
  require 'view_component/engine'
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: '2.3'
4
+ version: 2.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - kalashnikovisme
@@ -243,6 +243,7 @@ files:
243
243
  - app/views/kaminari/_page.html.haml
244
244
  - app/views/kaminari/_paginator.html.haml
245
245
  - app/views/kaminari/_prev_page.html.haml
246
+ - app/views/tramway/chats/_message.html.haml
246
247
  - app/views/tramway/entities/_form.html.haml
247
248
  - app/views/tramway/entities/_list.html.haml
248
249
  - app/views/tramway/entities/edit.html.haml
@@ -261,6 +262,7 @@ files:
261
262
  - lib/tramway.rb
262
263
  - lib/tramway/base_decorator.rb
263
264
  - lib/tramway/base_form.rb
265
+ - lib/tramway/chats/broadcast.rb
264
266
  - lib/tramway/config.rb
265
267
  - lib/tramway/configs/entities/page.rb
266
268
  - lib/tramway/configs/entities/route.rb