easy-admin-rails 0.1.10 → 0.1.12

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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/easy_admin.base.js +3204 -1
  3. data/app/assets/builds/easy_admin.base.js.map +4 -4
  4. data/app/assets/builds/easy_admin.css +327 -0
  5. data/app/assets/stylesheets/easy_admin/application.tailwind.css +1 -0
  6. data/app/assets/stylesheets/easy_admin/swal_customizations.css +168 -0
  7. data/app/components/easy_admin/fields/base_component.rb +23 -0
  8. data/app/components/easy_admin/fields/form/boolean_component.rb +1 -0
  9. data/app/components/easy_admin/fields/form/date_component.rb +3 -2
  10. data/app/components/easy_admin/fields/form/datetime_component.rb +3 -2
  11. data/app/components/easy_admin/fields/form/email_component.rb +3 -2
  12. data/app/components/easy_admin/fields/form/file_component.rb +6 -7
  13. data/app/components/easy_admin/fields/form/number_component.rb +3 -2
  14. data/app/components/easy_admin/fields/form/select_component.rb +5 -3
  15. data/app/components/easy_admin/fields/form/text_component.rb +3 -2
  16. data/app/components/easy_admin/fields/form/textarea_component.rb +3 -2
  17. data/app/components/easy_admin/versions/diff_component.rb +184 -0
  18. data/app/components/easy_admin/versions/diff_modal_component.rb +314 -0
  19. data/app/components/easy_admin/versions/history_component.rb +141 -0
  20. data/app/components/easy_admin/versions/pagination_component.rb +206 -0
  21. data/app/components/easy_admin/versions/timeline_component.rb +412 -0
  22. data/app/concerns/easy_admin/resource_versions.rb +306 -0
  23. data/app/controllers/easy_admin/application_controller.rb +8 -0
  24. data/app/controllers/easy_admin/resources_controller.rb +5 -3
  25. data/app/javascript/controllers/modal_controller.js +38 -0
  26. data/app/javascript/easy_admin/controllers/version_revert_controller.js +108 -0
  27. data/app/javascript/easy_admin/controllers.js +3 -1
  28. data/config/routes.rb +5 -0
  29. data/lib/easy-admin-rails.rb +1 -0
  30. data/lib/easy_admin/resource.rb +15 -0
  31. data/lib/easy_admin/version.rb +1 -1
  32. data/lib/easy_admin/versioning.rb +43 -0
  33. metadata +12 -2
@@ -18,6 +18,7 @@ module EasyAdmin
18
18
  class: input_classes,
19
19
  required: required?
20
20
  )
21
+ render_field_errors
21
22
  if field[:help_text]
22
23
  p(class: "mt-1 text-sm text-gray-500") { field[:help_text] }
23
24
  end
@@ -32,8 +33,8 @@ module EasyAdmin
32
33
 
33
34
  def input_classes
34
35
  base_classes = "block w-full px-3 py-2 border rounded-md shadow-sm text-sm"
35
- state_classes = "border-gray-300 placeholder-gray-400"
36
- focus_classes = "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
36
+ state_classes = has_field_errors? ? "border-red-300 placeholder-red-400" : "border-gray-300 placeholder-gray-400"
37
+ focus_classes = has_field_errors? ? "focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500" : "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
37
38
  hover_classes = "hover:border-gray-400"
38
39
  transition_classes = "transition-colors duration-200"
39
40
 
@@ -18,6 +18,7 @@ module EasyAdmin
18
18
  class: input_classes,
19
19
  required: required?
20
20
  )
21
+ render_field_errors
21
22
  if field[:help_text]
22
23
  p(class: "mt-1 text-sm text-gray-500") { field[:help_text] }
23
24
  end
@@ -32,8 +33,8 @@ module EasyAdmin
32
33
 
33
34
  def input_classes
34
35
  base_classes = "block w-full px-3 py-2 border rounded-md shadow-sm text-sm"
35
- state_classes = "border-gray-300 placeholder-gray-400"
36
- focus_classes = "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
36
+ state_classes = has_field_errors? ? "border-red-300 placeholder-red-400" : "border-gray-300 placeholder-gray-400"
37
+ focus_classes = has_field_errors? ? "focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500" : "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
37
38
  hover_classes = "hover:border-gray-400"
38
39
  transition_classes = "transition-colors duration-200"
39
40
 
@@ -19,6 +19,7 @@ module EasyAdmin
19
19
  required: required?,
20
20
  placeholder: field[:placeholder] || "Enter #{field_label.downcase}"
21
21
  )
22
+ render_field_errors
22
23
  if field[:help_text]
23
24
  p(class: "mt-1 text-sm text-gray-500") { field[:help_text] }
24
25
  end
@@ -33,8 +34,8 @@ module EasyAdmin
33
34
 
34
35
  def input_classes
35
36
  base_classes = "block w-full px-3 py-2 border rounded-md shadow-sm text-sm"
36
- state_classes = "border-gray-300 placeholder-gray-400"
37
- focus_classes = "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
37
+ state_classes = has_field_errors? ? "border-red-300 placeholder-red-400" : "border-gray-300 placeholder-gray-400"
38
+ focus_classes = has_field_errors? ? "focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500" : "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
38
39
  hover_classes = "hover:border-gray-400"
39
40
  transition_classes = "transition-colors duration-200"
40
41
 
@@ -8,6 +8,7 @@ module EasyAdmin
8
8
  div(class: "mb-6", data: { controller: "file" }) do
9
9
  render_label if field_label
10
10
  render_file_input
11
+ render_field_errors
11
12
  render_preview_area
12
13
  render_help_text if field[:help_text]
13
14
  render_current_file if current_file_exists?
@@ -28,7 +29,7 @@ module EasyAdmin
28
29
  type: "file",
29
30
  id: field_id,
30
31
  name: form_field_name,
31
- class: css_classes("block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 file:me-4 file:py-2 file:px-4 file:rounded-s-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100", size_class, validation_classes),
32
+ class: css_classes("block w-full text-sm text-gray-900 border rounded-lg cursor-pointer bg-gray-50 file:me-4 file:py-2 file:px-4 file:rounded-s-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100", size_class, file_input_error_classes),
32
33
  data: { file_target: "input" },
33
34
  **file_input_attributes
34
35
  )
@@ -112,13 +113,11 @@ module EasyAdmin
112
113
  end
113
114
  end
114
115
 
115
- def validation_classes
116
- return "" unless form&.object&.errors
117
-
118
- if form.object.errors[field_name].any?
119
- "border-red-300 focus:border-red-500 focus:ring-red-500"
116
+ def file_input_error_classes
117
+ if has_field_errors?
118
+ "border-red-300 focus:border-red-500 focus:ring-2 focus:ring-red-500"
120
119
  else
121
- ""
120
+ "border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
122
121
  end
123
122
  end
124
123
 
@@ -19,6 +19,7 @@ module EasyAdmin
19
19
  required: required?,
20
20
  placeholder: field[:placeholder] || "Enter #{field_label.downcase}"
21
21
  )
22
+ render_field_errors
22
23
  if field[:help_text]
23
24
  p(class: "mt-1 text-sm text-gray-500") { field[:help_text] }
24
25
  end
@@ -33,8 +34,8 @@ module EasyAdmin
33
34
 
34
35
  def input_classes
35
36
  base_classes = "block w-full px-3 py-2 border rounded-md shadow-sm text-sm"
36
- state_classes = "border-gray-300 placeholder-gray-400"
37
- focus_classes = "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
37
+ state_classes = has_field_errors? ? "border-red-300 placeholder-red-400" : "border-gray-300 placeholder-gray-400"
38
+ focus_classes = has_field_errors? ? "focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500" : "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
38
39
  hover_classes = "hover:border-gray-400"
39
40
  transition_classes = "transition-colors duration-200"
40
41
 
@@ -32,6 +32,7 @@ module EasyAdmin
32
32
  render_hidden_inputs
33
33
  end
34
34
 
35
+ render_field_errors
35
36
  if field[:help_text]
36
37
  p(class: "mt-1 text-sm text-gray-500") { field[:help_text] }
37
38
  end
@@ -91,7 +92,8 @@ module EasyAdmin
91
92
  end
92
93
 
93
94
  def render_multiple_select_container
94
- div(class: "relative border border-gray-300 rounded-md bg-white min-h-10 focus-within:ring-1 focus-within:ring-blue-500 focus-within:border-blue-500 transition-colors duration-200") do
95
+ error_classes = has_field_errors? ? "border-red-300 focus-within:ring-red-500 focus-within:border-red-500" : "border-gray-300 focus-within:ring-blue-500 focus-within:border-blue-500"
96
+ div(class: "relative border #{error_classes} rounded-md bg-white min-h-10 focus-within:ring-1 transition-colors duration-200") do
95
97
  div(class: "flex flex-wrap items-center gap-1 p-2 pr-8") do
96
98
  # Selected items container - will be populated by Stimulus controller
97
99
  div(class: "flex flex-wrap items-center gap-1", data: { select_field_target: "selectedItems" }) do
@@ -163,8 +165,8 @@ module EasyAdmin
163
165
 
164
166
  def single_select_input_classes
165
167
  base_classes = "block w-full px-3 py-2 pr-10 text-sm border rounded-md cursor-pointer"
166
- state_classes = "border-gray-300 placeholder-gray-400 bg-white"
167
- focus_classes = "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
168
+ state_classes = has_field_errors? ? "border-red-300 placeholder-red-400 bg-white" : "border-gray-300 placeholder-gray-400 bg-white"
169
+ focus_classes = has_field_errors? ? "focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500" : "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
168
170
  hover_classes = "hover:border-gray-400"
169
171
  transition_classes = "transition-colors duration-200"
170
172
 
@@ -19,6 +19,7 @@ module EasyAdmin
19
19
  required: required?,
20
20
  placeholder: field[:placeholder] || "Enter #{field_label.downcase}"
21
21
  )
22
+ render_field_errors
22
23
  if field[:help_text]
23
24
  p(class: "mt-1 text-sm text-gray-500") { field[:help_text] }
24
25
  end
@@ -33,8 +34,8 @@ module EasyAdmin
33
34
 
34
35
  def input_classes
35
36
  base_classes = "block w-full px-3 py-2 border rounded-md shadow-sm text-sm"
36
- state_classes = "border-gray-300 placeholder-gray-400"
37
- focus_classes = "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
37
+ state_classes = has_field_errors? ? "border-red-300 placeholder-red-400" : "border-gray-300 placeholder-gray-400"
38
+ focus_classes = has_field_errors? ? "focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500" : "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
38
39
  hover_classes = "hover:border-gray-400"
39
40
  transition_classes = "transition-colors duration-200"
40
41
 
@@ -18,6 +18,7 @@ module EasyAdmin
18
18
  rows: field[:rows] || 4,
19
19
  placeholder: field[:placeholder] || "Enter #{field_label.downcase}"
20
20
  ) { current_value }
21
+ render_field_errors
21
22
  if field[:help_text]
22
23
  p(class: "mt-1 text-sm text-gray-500") { field[:help_text] }
23
24
  end
@@ -32,8 +33,8 @@ module EasyAdmin
32
33
 
33
34
  def textarea_classes
34
35
  base_classes = "block w-full px-3 py-2 border rounded-md shadow-sm text-sm resize-y"
35
- state_classes = "border-gray-300 placeholder-gray-400"
36
- focus_classes = "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
36
+ state_classes = has_field_errors? ? "border-red-300 placeholder-red-400" : "border-gray-300 placeholder-gray-400"
37
+ focus_classes = has_field_errors? ? "focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500" : "focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
37
38
  hover_classes = "hover:border-gray-400"
38
39
  transition_classes = "transition-colors duration-200"
39
40
 
@@ -0,0 +1,184 @@
1
+ module EasyAdmin
2
+ module Versions
3
+ class DiffComponent < BaseComponent
4
+ def initialize(version:, display_mode: :inline)
5
+ @version = version
6
+ @display_mode = display_mode
7
+ @changes = extract_changes
8
+ end
9
+
10
+ def view_template
11
+ div(class: "version-diff") do
12
+ render_header
13
+ render_diff_content
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def render_header
20
+ div(class: "border-b pb-4 mb-4") do
21
+ h4(class: "text-lg font-semibold mb-2") do
22
+ "Version ##{@version.id} - #{@version.event.capitalize}"
23
+ end
24
+
25
+ div(class: "flex items-center gap-4 text-sm text-gray-600") do
26
+ span { "#{time_ago_in_words(@version.created_at)} ago" }
27
+ span { @version.created_at.strftime("%B %d, %Y at %I:%M %p") }
28
+
29
+ if @version.whodunnit
30
+ span(class: "px-2 py-1 bg-gray-100 rounded") { "User ##{@version.whodunnit}" }
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def render_diff_content
37
+ if @changes.any?
38
+ div(class: "space-y-4") do
39
+ @changes.each do |field, change|
40
+ render_field_diff(field, change)
41
+ end
42
+ end
43
+ else
44
+ render_no_changes
45
+ end
46
+ end
47
+
48
+ def render_field_diff(field, change)
49
+ div(class: "border rounded-lg p-4") do
50
+ h5(class: "font-medium text-gray-900 mb-3") { field.humanize }
51
+
52
+ case @display_mode
53
+ when :inline
54
+ render_inline_diff(change)
55
+ when :side_by_side
56
+ render_side_by_side_diff(change)
57
+ else
58
+ render_inline_diff(change)
59
+ end
60
+ end
61
+ end
62
+
63
+ def render_inline_diff(change)
64
+ div(class: "space-y-2") do
65
+ if change[:old]
66
+ div(class: "flex items-center gap-2") do
67
+ span(class: "text-sm font-medium text-red-600") { "Before:" }
68
+ span(class: "px-2 py-1 bg-red-100 text-red-800 rounded text-sm line-through") do
69
+ format_value_for_display(change[:old])
70
+ end
71
+ end
72
+ end
73
+
74
+ if change[:new]
75
+ div(class: "flex items-center gap-2") do
76
+ span(class: "text-sm font-medium text-green-600") { "After:" }
77
+ span(class: "px-2 py-1 bg-green-100 text-green-800 rounded text-sm") do
78
+ format_value_for_display(change[:new])
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def render_side_by_side_diff(change)
86
+ div(class: "grid grid-cols-2 gap-4") do
87
+ # Before column
88
+ div do
89
+ h6(class: "text-sm font-medium text-red-600 mb-2") { "Before" }
90
+ div(class: "p-3 bg-red-50 border border-red-200 rounded text-sm") do
91
+ if change[:old]
92
+ span(class: "text-red-800") { format_value_for_display(change[:old]) }
93
+ else
94
+ span(class: "text-gray-400 italic") { "Not set" }
95
+ end
96
+ end
97
+ end
98
+
99
+ # After column
100
+ div do
101
+ h6(class: "text-sm font-medium text-green-600 mb-2") { "After" }
102
+ div(class: "p-3 bg-green-50 border border-green-200 rounded text-sm") do
103
+ if change[:new]
104
+ span(class: "text-green-800") { format_value_for_display(change[:new]) }
105
+ else
106
+ span(class: "text-gray-400 italic") { "Removed" }
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ def render_no_changes
114
+ div(class: "text-center py-8 text-gray-500") do
115
+ p { "No changes detected in this version" }
116
+ end
117
+ end
118
+
119
+ def extract_changes
120
+ return {} unless @version.object
121
+
122
+ begin
123
+ old_attrs = parse_version_object(@version.object)
124
+ current_item = @version.item
125
+
126
+ return {} unless current_item && old_attrs.is_a?(Hash)
127
+
128
+ changes = {}
129
+ current_attrs = current_item.attributes
130
+
131
+ (old_attrs.keys + current_attrs.keys).uniq.each do |key|
132
+ next if skip_field?(key)
133
+
134
+ old_val = old_attrs[key]
135
+ new_val = current_attrs[key]
136
+
137
+ if old_val != new_val
138
+ changes[key] = { old: old_val, new: new_val }
139
+ end
140
+ end
141
+
142
+ changes
143
+ rescue => e
144
+ Rails.logger.error "Error parsing version #{@version.id}: #{e.message}"
145
+ {}
146
+ end
147
+ end
148
+
149
+ def parse_version_object(object_yaml)
150
+ YAML.safe_load(object_yaml, permitted_classes: [Time, Date, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone])
151
+ rescue Psych::DisallowedClass
152
+ # Fallback for ActiveSupport objects
153
+ YAML.load(object_yaml, aliases: true)
154
+ rescue
155
+ {}
156
+ end
157
+
158
+ def skip_field?(field)
159
+ %w[id created_at updated_at].include?(field.to_s)
160
+ end
161
+
162
+ def format_value_for_display(value)
163
+ case value
164
+ when Time, DateTime, ActiveSupport::TimeWithZone
165
+ value.strftime("%Y-%m-%d %H:%M:%S")
166
+ when true
167
+ "✓ Yes"
168
+ when false
169
+ "✗ No"
170
+ when nil
171
+ span(class: "text-gray-400 italic") { "empty" }
172
+ else
173
+ # Truncate very long values
174
+ str = value.to_s
175
+ if str.length > 100
176
+ truncate(str, length: 100)
177
+ else
178
+ str
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,314 @@
1
+ module EasyAdmin
2
+ module Versions
3
+ class DiffModalComponent < BaseComponent
4
+ def initialize(version:, record:, resource_class:)
5
+ @version = version
6
+ @record = record
7
+ @resource_class = resource_class
8
+ end
9
+
10
+ def view_template
11
+ turbo_frame(id: "modal") do
12
+ # Modal backdrop
13
+ div(
14
+ id: "modal-backdrop",
15
+ class: "fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50 opacity-100 transition-opacity duration-300",
16
+ data: { controller: "modal", action: "click->modal#closeOnBackdrop" }
17
+ ) do
18
+ # Modal container
19
+ div(
20
+ class: "relative w-full max-w-4xl max-h-[90vh] bg-white rounded-lg shadow-xl overflow-hidden"
21
+ ) do
22
+ render_modal_header
23
+ render_modal_body
24
+ render_modal_footer
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def render_modal_header
33
+ div(class: "px-6 py-4 border-b border-gray-200 bg-gray-50 flex items-center justify-between") do
34
+ div do
35
+ h2(class: "text-lg font-semibold text-gray-900") do
36
+ "Version Details ##{@version.id}"
37
+ end
38
+ p(class: "text-sm text-gray-600 mt-1") do
39
+ "#{@version.event.capitalize} • #{time_ago_in_words(@version.created_at)} ago"
40
+ end
41
+ end
42
+
43
+ button(
44
+ class: "text-gray-400 hover:text-gray-600 transition-colors",
45
+ data: { action: "click->modal#close" }
46
+ ) do
47
+ unsafe_raw <<~SVG
48
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
49
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
50
+ </svg>
51
+ SVG
52
+ end
53
+ end
54
+ end
55
+
56
+ def render_modal_body
57
+ div(class: "px-6 py-4 max-h-96 overflow-y-auto") do
58
+ if @version.object.blank?
59
+ div(class: "text-center py-8 text-gray-500") do
60
+ "No change data available for this version"
61
+ end
62
+ return
63
+ end
64
+
65
+ begin
66
+ old_attrs = parse_version_object(@version.object)
67
+ changes = extract_changes(@version, old_attrs)
68
+
69
+ if changes.any?
70
+ div(class: "space-y-6") do
71
+ changes.each do |field, change|
72
+ render_detailed_field_change(field, change)
73
+ end
74
+ end
75
+ else
76
+ div(class: "text-center py-8 text-gray-500") do
77
+ "No visible changes detected"
78
+ end
79
+ end
80
+ rescue => e
81
+ div(class: "text-red-600 bg-red-50 border border-red-200 rounded-lg p-4") do
82
+ "Error parsing version data: #{e.message}"
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ def render_modal_footer
89
+ div(class: "px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-between") do
90
+ div(class: "text-sm text-gray-500") do
91
+ if @version.whodunnit
92
+ "Changed by: #{@version.whodunnit}"
93
+ else
94
+ "Changed by: System"
95
+ end
96
+ end
97
+
98
+ div(class: "flex items-center space-x-3") do
99
+ button(
100
+ class: "px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors",
101
+ data: { action: "click->modal#close" }
102
+ ) do
103
+ "Close"
104
+ end
105
+
106
+ button(
107
+ class: "px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 transition-colors",
108
+ data: {
109
+ controller: "version-revert",
110
+ action: "click->version-revert#confirm",
111
+ version_revert_version_id_value: @version.id,
112
+ version_revert_resource_id_value: @record.id,
113
+ version_revert_resource_name_value: @resource_class.route_key
114
+ }
115
+ ) do
116
+ "Revert to This Version"
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ def render_detailed_field_change(field, change)
123
+ # Match the timeline field change styling
124
+ div(class: "bg-gray-50 rounded-xl p-4 border border-gray-100") do
125
+ # Field name - using same styling as timeline
126
+ div(class: "text-sm font-semibold text-gray-900 mb-3") do
127
+ span { field.humanize }
128
+ end
129
+
130
+ # Change display - use same responsive layout as timeline
131
+ div(class: "flex flex-col sm:flex-row sm:items-center space-y-3 sm:space-y-0 sm:space-x-4") do
132
+ if change.has_key?(:old)
133
+ div(class: "flex-1") do
134
+ div(class: "text-xs font-medium text-gray-500 uppercase tracking-wide mb-1") { "From" }
135
+ div(class: "text-sm text-gray-900 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm") do
136
+ format_detailed_value(change[:old])
137
+ end
138
+ end
139
+ end
140
+
141
+ # Arrow pointing from old to new - same as timeline
142
+ if change.has_key?(:old) && change.has_key?(:new)
143
+ div(class: "flex-shrink-0 text-gray-400 flex justify-center sm:block") do
144
+ # Vertical arrow for mobile
145
+ unsafe_raw <<~SVG
146
+ <svg class="h-4 w-4 sm:hidden" viewBox="0 0 20 20" fill="currentColor">
147
+ <path fill-rule="evenodd" d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z" clip-rule="evenodd"/>
148
+ </svg>
149
+ SVG
150
+ # Horizontal arrow for desktop
151
+ unsafe_raw <<~SVG
152
+ <svg class="h-4 w-4 hidden sm:block" viewBox="0 0 20 20" fill="currentColor">
153
+ <path fill-rule="evenodd" d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z" clip-rule="evenodd"/>
154
+ </svg>
155
+ SVG
156
+ end
157
+ end
158
+
159
+ if change.has_key?(:new)
160
+ div(class: "flex-1") do
161
+ div(class: "text-xs font-medium text-gray-500 uppercase tracking-wide mb-1") { "To" }
162
+ div(class: "text-sm text-gray-900 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm") do
163
+ format_detailed_value(change[:new])
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+
171
+ def format_detailed_value(value)
172
+ case value
173
+ when Time, DateTime, ActiveSupport::TimeWithZone
174
+ value.strftime("%B %d, %Y at %I:%M %p")
175
+ when true
176
+ "Yes"
177
+ when false
178
+ "No"
179
+ when nil
180
+ span(class: "text-gray-400 italic") { "empty" }
181
+ when String
182
+ # Try to parse as JSON for better formatting
183
+ if looks_like_json?(value)
184
+ format_json_value(value)
185
+ else
186
+ value
187
+ end
188
+ else
189
+ # Don't truncate in detailed view
190
+ value.to_s
191
+ end
192
+ end
193
+
194
+ private
195
+
196
+ def looks_like_json?(string)
197
+ return false if string.blank?
198
+
199
+ # Check for regular JSON
200
+ regular_json = string.strip.start_with?('{', '[') || string == '0' || string == 'null' || string =~ /^\d+$/
201
+
202
+ # Check for single-quoted JSON strings (like "{\"key\":\"value\"}")
203
+ quoted_json = string.strip.start_with?('"{') || string.strip.start_with?('"[')
204
+
205
+ regular_json || quoted_json
206
+ end
207
+
208
+ def format_json_value(json_string)
209
+ return json_string if json_string.blank?
210
+
211
+ # Handle simple cases - including numeric strings that represent empty JSON
212
+ case json_string.strip
213
+ when '0', '1', '2', 'null', '""'
214
+ span(class: "text-gray-500 italic") { "empty" }
215
+ else
216
+ begin
217
+ # Try to handle quoted JSON strings
218
+ cleaned_string = json_string
219
+ if json_string.strip.start_with?('"{') || json_string.strip.start_with?('"[')
220
+ # Remove outer quotes and parse as JSON
221
+ cleaned_string = JSON.parse(json_string)
222
+ end
223
+
224
+ parsed = JSON.parse(cleaned_string)
225
+ if parsed.is_a?(Hash) && parsed.empty?
226
+ span(class: "text-gray-500 italic") { "empty object" }
227
+ elsif parsed.is_a?(Array) && parsed.empty?
228
+ span(class: "text-gray-500 italic") { "empty array" }
229
+ else
230
+ # Format as pretty JSON
231
+ pre(class: "bg-gray-100 p-2 rounded text-sm font-mono whitespace-pre-wrap") do
232
+ JSON.pretty_generate(parsed)
233
+ end
234
+ end
235
+ rescue JSON::ParserError
236
+ # Not valid JSON, return as-is
237
+ json_string
238
+ end
239
+ end
240
+ end
241
+
242
+ # Reuse methods from TimelineComponent
243
+ def parse_version_object(object_yaml)
244
+ return {} if object_yaml.blank?
245
+
246
+ YAML.safe_load(
247
+ object_yaml,
248
+ permitted_classes: [Time, Date, DateTime, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, Symbol],
249
+ aliases: true
250
+ )
251
+ rescue Psych::DisallowedClass, Psych::BadAlias
252
+ begin
253
+ YAML.unsafe_load(object_yaml)
254
+ rescue
255
+ extract_basic_attributes(object_yaml)
256
+ end
257
+ rescue => e
258
+ Rails.logger.warn "Failed to parse version object: #{e.message}"
259
+ {}
260
+ end
261
+
262
+ def extract_basic_attributes(yaml_string)
263
+ attributes = {}
264
+
265
+ yaml_string.scan(/^(\w+):\s*(.+)$/).each do |key, value|
266
+ next if value.include?('!ruby/object')
267
+
268
+ cleaned_value = value.strip.gsub(/^["']|["']$/, '')
269
+ attributes[key] = cleaned_value unless cleaned_value.empty?
270
+ end
271
+
272
+ attributes
273
+ rescue
274
+ {}
275
+ end
276
+
277
+ def extract_changes(version, old_attrs)
278
+ return {} unless old_attrs.is_a?(Hash)
279
+
280
+ current_item = version.item
281
+ return {} unless current_item
282
+
283
+ changes = {}
284
+ current_attrs = current_item.attributes
285
+
286
+ (old_attrs.keys + current_attrs.keys).uniq.each do |key|
287
+ next if %w[id updated_at].include?(key.to_s)
288
+
289
+ old_val = normalize_value_for_comparison(old_attrs[key])
290
+ new_val = normalize_value_for_comparison(current_attrs[key])
291
+
292
+ if old_val != new_val
293
+ changes[key] = { old: old_attrs[key], new: current_attrs[key] }
294
+ end
295
+ end
296
+
297
+ changes
298
+ end
299
+
300
+ def normalize_value_for_comparison(value)
301
+ case value
302
+ when true, "true", "t", "1", 1
303
+ true
304
+ when false, "false", "f", "0", 0
305
+ false
306
+ when nil, ""
307
+ nil
308
+ else
309
+ value
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end