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.
- checksums.yaml +4 -4
- data/app/assets/builds/easy_admin.base.js +3204 -1
- data/app/assets/builds/easy_admin.base.js.map +4 -4
- data/app/assets/builds/easy_admin.css +327 -0
- data/app/assets/stylesheets/easy_admin/application.tailwind.css +1 -0
- data/app/assets/stylesheets/easy_admin/swal_customizations.css +168 -0
- data/app/components/easy_admin/versions/diff_component.rb +184 -0
- data/app/components/easy_admin/versions/diff_modal_component.rb +314 -0
- data/app/components/easy_admin/versions/history_component.rb +141 -0
- data/app/components/easy_admin/versions/pagination_component.rb +206 -0
- data/app/components/easy_admin/versions/timeline_component.rb +412 -0
- data/app/concerns/easy_admin/resource_versions.rb +306 -0
- data/app/controllers/easy_admin/application_controller.rb +8 -0
- data/app/controllers/easy_admin/resources_controller.rb +3 -1
- data/app/javascript/controllers/modal_controller.js +38 -0
- data/app/javascript/easy_admin/controllers/version_revert_controller.js +108 -0
- data/app/javascript/easy_admin/controllers.js +3 -1
- data/config/routes.rb +5 -0
- data/lib/easy-admin-rails.rb +1 -0
- data/lib/easy_admin/resource.rb +15 -0
- data/lib/easy_admin/version.rb +1 -1
- data/lib/easy_admin/versioning.rb +43 -0
- metadata +12 -2
@@ -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
|