easy-admin-rails 0.1.11 → 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.
@@ -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
@@ -0,0 +1,141 @@
1
+ module EasyAdmin
2
+ module Versions
3
+ class HistoryComponent < BaseComponent
4
+ def initialize(record:, resource_class:, versions_result: nil)
5
+ @record = record
6
+ @resource_class = resource_class
7
+ @versions_result = versions_result
8
+ end
9
+
10
+ def view_template
11
+ div(id: "versions-section", class: "space-y-6") do
12
+ if versioning_available?
13
+ if has_versions?
14
+ render_versions_section
15
+ else
16
+ render_no_versions_message
17
+ end
18
+ else
19
+ render_versioning_unavailable_message
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def versioning_available?
27
+ return false unless defined?(PaperTrail)
28
+ @record.respond_to?(:versions)
29
+ rescue
30
+ false
31
+ end
32
+
33
+ def has_versions?
34
+ return @versions_result.total_count > 0 if @versions_result
35
+ @record.versions.any?
36
+ end
37
+
38
+ def render_versions_section
39
+ div(class: "bg-white shadow-sm rounded-lg border") do
40
+ render_section_header
41
+ render_timeline_content
42
+ render_pagination if @versions_result&.pagy&.pages&.> 1
43
+ end
44
+ end
45
+
46
+ def render_section_header
47
+ div(class: "px-4 sm:px-6 py-4 border-b border-gray-200") do
48
+ h3(class: "text-lg font-medium text-gray-900") { "Version History" }
49
+ p(class: "mt-1 text-sm text-gray-600") do
50
+ total = @versions_result&.total_count || @record.versions.count
51
+ "#{total} versions • Showing recent changes"
52
+ end
53
+ end
54
+ end
55
+
56
+ def render_timeline_content
57
+ div(class: "p-4 sm:p-6") do
58
+ if @versions_result
59
+ # Use paginated versions from the concern
60
+ render EasyAdmin::Versions::TimelineComponent.new(
61
+ versions: @versions_result.items,
62
+ resource_class: @resource_class,
63
+ record: @record
64
+ )
65
+ else
66
+ # Fallback to loading first 10 versions directly for lazy loading
67
+ turbo_frame(id: "versions-timeline", src: versions_url, loading: "lazy") do
68
+ render_timeline_skeleton
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ def render_timeline_skeleton
75
+ div(class: "animate-pulse space-y-6") do
76
+ 3.times do
77
+ div(class: "bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden") do
78
+ div(class: "px-4 sm:px-6 py-4 border-b border-gray-100 bg-gray-50/50") do
79
+ div(class: "flex items-center space-x-3") do
80
+ div(class: "h-6 bg-gray-200 rounded w-20")
81
+ div(class: "h-4 bg-gray-200 rounded w-32")
82
+ end
83
+ end
84
+ div(class: "p-4 sm:p-6") do
85
+ div(class: "h-4 bg-gray-200 rounded w-full mb-2")
86
+ div(class: "h-4 bg-gray-200 rounded w-3/4")
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ def render_pagination
94
+ render EasyAdmin::Versions::PaginationComponent.new(
95
+ pagy: @versions_result.pagy,
96
+ resource_class: @resource_class,
97
+ record: @record
98
+ )
99
+ end
100
+
101
+ def render_no_versions_message
102
+ div(class: "text-center py-12 bg-gray-50 rounded-lg") do
103
+ div(class: "text-gray-400 mb-4") do
104
+ unsafe_raw <<~SVG
105
+ <svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
106
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
107
+ </svg>
108
+ SVG
109
+ end
110
+ h3(class: "text-lg font-medium text-gray-900 mb-2") { "No Version History" }
111
+ p(class: "text-gray-600") { "No changes have been tracked for this record yet." }
112
+ end
113
+ end
114
+
115
+ def render_versioning_unavailable_message
116
+ div(class: "text-center py-12 bg-yellow-50 rounded-lg border border-yellow-200") do
117
+ div(class: "text-yellow-400 mb-4") do
118
+ unsafe_raw <<~SVG
119
+ <svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
120
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
121
+ </svg>
122
+ SVG
123
+ end
124
+ h3(class: "text-lg font-medium text-gray-900 mb-2") { "Versioning Not Available" }
125
+ p(class: "text-gray-600") do
126
+ "Version tracking is not configured for this resource. Add "
127
+ code(class: "bg-gray-100 px-2 py-1 rounded text-sm") { "has_paper_trail" }
128
+ " to the model to enable version history."
129
+ end
130
+ end
131
+ end
132
+
133
+ def versions_url
134
+ easy_admin_url_helpers.resource_versions_path(
135
+ @resource_class.route_key,
136
+ @record.id
137
+ )
138
+ end
139
+ end
140
+ end
141
+ end