easy-admin-rails 0.1.11 → 0.1.13
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 +183 -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 +415 -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,206 @@
|
|
1
|
+
module EasyAdmin
|
2
|
+
module Versions
|
3
|
+
class PaginationComponent < BaseComponent
|
4
|
+
def initialize(pagy:, resource_class:, record:)
|
5
|
+
@pagy = pagy
|
6
|
+
@resource_class = resource_class
|
7
|
+
@record = record
|
8
|
+
end
|
9
|
+
|
10
|
+
def view_template
|
11
|
+
return unless @pagy.pages > 1
|
12
|
+
|
13
|
+
div(id: "versions-pagination", class: "mt-6 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6") do
|
14
|
+
# Mobile pagination info
|
15
|
+
div(class: "flex flex-1 justify-between sm:hidden") do
|
16
|
+
if @pagy.prev
|
17
|
+
link_to_previous_page
|
18
|
+
else
|
19
|
+
span(class: "relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-500") do
|
20
|
+
"Previous"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
if @pagy.next
|
25
|
+
link_to_next_page
|
26
|
+
else
|
27
|
+
span(class: "relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-500") do
|
28
|
+
"Next"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Desktop pagination
|
34
|
+
div(class: "hidden sm:flex sm:flex-1 sm:items-center sm:justify-between") do
|
35
|
+
# Results info
|
36
|
+
div do
|
37
|
+
p(class: "text-sm text-gray-700") do
|
38
|
+
plain "Showing "
|
39
|
+
span(class: "font-medium") { @pagy.from }
|
40
|
+
plain " to "
|
41
|
+
span(class: "font-medium") { @pagy.to }
|
42
|
+
plain " of "
|
43
|
+
span(class: "font-medium") { @pagy.count }
|
44
|
+
plain " versions"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Page navigation
|
49
|
+
div do
|
50
|
+
nav(class: "isolate inline-flex -space-x-px rounded-md shadow-sm", "aria-label": "Pagination") do
|
51
|
+
# Previous page
|
52
|
+
if @pagy.prev
|
53
|
+
link_to_previous_page_desktop
|
54
|
+
else
|
55
|
+
span(class: "relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0") do
|
56
|
+
span(class: "sr-only") { "Previous" }
|
57
|
+
unsafe_raw <<~SVG
|
58
|
+
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
59
|
+
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd"/>
|
60
|
+
</svg>
|
61
|
+
SVG
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Page numbers (show first, current range, and last)
|
66
|
+
render_page_numbers
|
67
|
+
|
68
|
+
# Next page
|
69
|
+
if @pagy.next
|
70
|
+
link_to_next_page_desktop
|
71
|
+
else
|
72
|
+
span(class: "relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0") do
|
73
|
+
span(class: "sr-only") { "Next" }
|
74
|
+
unsafe_raw <<~SVG
|
75
|
+
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
76
|
+
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd"/>
|
77
|
+
</svg>
|
78
|
+
SVG
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
def link_to_previous_page
|
90
|
+
a(
|
91
|
+
href: versions_url(page: @pagy.prev),
|
92
|
+
data: { turbo_frame: "versions-section" },
|
93
|
+
class: "relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
94
|
+
) do
|
95
|
+
"Previous"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def link_to_next_page
|
100
|
+
a(
|
101
|
+
href: versions_url(page: @pagy.next),
|
102
|
+
data: { turbo_frame: "versions-section" },
|
103
|
+
class: "relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
104
|
+
) do
|
105
|
+
"Next"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def link_to_previous_page_desktop
|
110
|
+
a(
|
111
|
+
href: versions_url(page: @pagy.prev),
|
112
|
+
data: { turbo_frame: "versions-section" },
|
113
|
+
class: "relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
114
|
+
) do
|
115
|
+
span(class: "sr-only") { "Previous" }
|
116
|
+
unsafe_raw <<~SVG
|
117
|
+
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
118
|
+
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd"/>
|
119
|
+
</svg>
|
120
|
+
SVG
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def link_to_next_page_desktop
|
125
|
+
a(
|
126
|
+
href: versions_url(page: @pagy.next),
|
127
|
+
data: { turbo_frame: "versions-section" },
|
128
|
+
class: "relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
129
|
+
) do
|
130
|
+
span(class: "sr-only") { "Next" }
|
131
|
+
unsafe_raw <<~SVG
|
132
|
+
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
133
|
+
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd"/>
|
134
|
+
</svg>
|
135
|
+
SVG
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def render_page_numbers
|
140
|
+
# Calculate range of pages to show
|
141
|
+
current = @pagy.page
|
142
|
+
total = @pagy.pages
|
143
|
+
|
144
|
+
# Show pages around current page
|
145
|
+
start_page = [current - 2, 1].max
|
146
|
+
end_page = [current + 2, total].min
|
147
|
+
|
148
|
+
# Always show page 1 if not in range
|
149
|
+
if start_page > 1
|
150
|
+
render_page_link(1)
|
151
|
+
if start_page > 2
|
152
|
+
span(class: "relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:outline-offset-0") do
|
153
|
+
"..."
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
# Show page range
|
159
|
+
(start_page..end_page).each do |page|
|
160
|
+
if page == current
|
161
|
+
render_current_page(page)
|
162
|
+
else
|
163
|
+
render_page_link(page)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
# Always show last page if not in range
|
168
|
+
if end_page < total
|
169
|
+
if end_page < total - 1
|
170
|
+
span(class: "relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:outline-offset-0") do
|
171
|
+
"..."
|
172
|
+
end
|
173
|
+
end
|
174
|
+
render_page_link(total)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def render_current_page(page)
|
179
|
+
span(
|
180
|
+
"aria-current": "page",
|
181
|
+
class: "relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-semibold text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
182
|
+
) do
|
183
|
+
page.to_s
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def render_page_link(page)
|
188
|
+
a(
|
189
|
+
href: versions_url(page: page),
|
190
|
+
data: { turbo_frame: "versions-section" },
|
191
|
+
class: "relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
|
192
|
+
) do
|
193
|
+
page.to_s
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
def versions_url(page:)
|
198
|
+
easy_admin_url_helpers.resource_versions_path(
|
199
|
+
@resource_class.route_key,
|
200
|
+
@record.id,
|
201
|
+
page: page
|
202
|
+
)
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,415 @@
|
|
1
|
+
module EasyAdmin
|
2
|
+
module Versions
|
3
|
+
class TimelineComponent < BaseComponent
|
4
|
+
def initialize(versions:, turbo_frame_id: nil, resource_class: nil, record: nil)
|
5
|
+
@versions = versions
|
6
|
+
@turbo_frame_id = turbo_frame_id
|
7
|
+
@resource_class = resource_class
|
8
|
+
@record = record
|
9
|
+
end
|
10
|
+
|
11
|
+
def view_template
|
12
|
+
content = lambda do
|
13
|
+
div(class: "w-full") do
|
14
|
+
# Timeline container with explicit vertical layout
|
15
|
+
div(class: "relative w-full") do
|
16
|
+
# Timeline line - centered with icons (hidden on mobile)
|
17
|
+
div(class: "absolute left-10 top-0 bottom-0 w-0.5 bg-gray-300 hidden sm:block")
|
18
|
+
|
19
|
+
# Version items - force vertical stacking (newest first)
|
20
|
+
@versions.each_with_index do |version, index|
|
21
|
+
div(class: "relative w-full mb-6 sm:mb-8") do
|
22
|
+
render_timeline_item_content(version, index)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
if @turbo_frame_id
|
30
|
+
turbo_frame(id: @turbo_frame_id) do
|
31
|
+
content.call
|
32
|
+
end
|
33
|
+
else
|
34
|
+
content.call
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def render_timeline_item_content(version, index)
|
41
|
+
div(class: "flex items-start") do
|
42
|
+
# Timeline dot - matches admin panel styling (hidden on mobile)
|
43
|
+
div(class: "absolute left-8 w-3 h-3 #{event_bg_color(version.event)} rounded-full z-10 shadow-sm hidden sm:block") do
|
44
|
+
# Simple dot design to match admin panel
|
45
|
+
end
|
46
|
+
|
47
|
+
# Content card - responsive margin (no left margin on mobile)
|
48
|
+
div(class: "sm:ml-16 flex-1") do
|
49
|
+
div(class: "bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow duration-200") do
|
50
|
+
render_item_header(version)
|
51
|
+
render_item_content(version)
|
52
|
+
render_item_actions(version)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def render_item_header(version)
|
59
|
+
# Match admin panel header style (like in BaseCardComponent) with mobile responsiveness
|
60
|
+
div(class: "px-4 sm:px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between") do
|
61
|
+
div(class: "flex items-center space-x-2 sm:space-x-3") do
|
62
|
+
# Event badge - using admin panel style
|
63
|
+
span(class: "inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600") do
|
64
|
+
version.event.capitalize
|
65
|
+
end
|
66
|
+
|
67
|
+
# Time ago
|
68
|
+
span(class: "text-sm font-medium text-gray-900") do
|
69
|
+
time_ago_in_words(version.created_at) + " ago"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
div(class: "flex items-center space-x-1 sm:space-x-2") do
|
74
|
+
# Exact timestamp - hide on mobile
|
75
|
+
span(class: "hidden sm:inline text-xs font-medium text-gray-500 uppercase tracking-wide") do
|
76
|
+
version.created_at.strftime("%m/%d %I:%M%p")
|
77
|
+
end
|
78
|
+
|
79
|
+
# User info - matching admin panel style
|
80
|
+
if version.whodunnit
|
81
|
+
span(class: "inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800") do
|
82
|
+
version.whodunnit
|
83
|
+
end
|
84
|
+
else
|
85
|
+
span(class: "inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600") do
|
86
|
+
"System"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def render_item_content(version)
|
94
|
+
# Match admin panel body padding (like in BaseCardComponent) - responsive padding
|
95
|
+
div(class: "p-4 sm:p-6") do
|
96
|
+
if version.object.blank?
|
97
|
+
div(class: "text-sm text-gray-500") { "No change data available" }
|
98
|
+
return
|
99
|
+
end
|
100
|
+
|
101
|
+
begin
|
102
|
+
old_attrs = parse_version_object(version.object)
|
103
|
+
changes = extract_changes(version, old_attrs)
|
104
|
+
|
105
|
+
if changes.any?
|
106
|
+
div(class: "space-y-4") do
|
107
|
+
# Show first 3 changes with better styling
|
108
|
+
changes.first(3).each do |field, change|
|
109
|
+
render_field_change(field, change)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Show count of remaining changes - using admin panel badge style
|
113
|
+
if changes.size > 3
|
114
|
+
div(class: "pt-4 border-t border-gray-100") do
|
115
|
+
span(class: "inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600") do
|
116
|
+
"+#{changes.size - 3} more changes"
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
else
|
122
|
+
div(class: "text-sm text-gray-500 flex items-center space-x-2") do
|
123
|
+
unsafe_raw '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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"></path></svg>'
|
124
|
+
span { "No visible changes detected" }
|
125
|
+
end
|
126
|
+
end
|
127
|
+
rescue => e
|
128
|
+
div(class: "text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3 flex items-center space-x-2") do
|
129
|
+
unsafe_raw '<svg class="w-4 h-4 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
|
130
|
+
span { "Error parsing version data: #{e.message}" }
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def render_field_change(field, change)
|
137
|
+
# Match admin panel card nested styling
|
138
|
+
div(class: "bg-gray-50 rounded-xl p-4 border border-gray-100") do
|
139
|
+
# Field name - using admin panel typography
|
140
|
+
div(class: "text-sm font-semibold text-gray-900 mb-3") do
|
141
|
+
span { field.humanize }
|
142
|
+
end
|
143
|
+
|
144
|
+
# Change display with arrow - responsive layout (horizontal on desktop, vertical on mobile)
|
145
|
+
div(class: "flex flex-col sm:flex-row sm:items-center space-y-3 sm:space-y-0 sm:space-x-4") do
|
146
|
+
if change.has_key?(:old)
|
147
|
+
div(class: "flex-1") do
|
148
|
+
div(class: "text-xs font-medium text-gray-500 uppercase tracking-wide mb-1") { "From" }
|
149
|
+
div(class: "text-sm text-gray-900 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm") do
|
150
|
+
format_value(change[:old])
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# Arrow pointing from old to new - horizontal on desktop, vertical on mobile
|
156
|
+
if change.has_key?(:old) && change.has_key?(:new)
|
157
|
+
div(class: "flex-shrink-0 text-gray-400 flex justify-center sm:block") do
|
158
|
+
# Vertical arrow for mobile
|
159
|
+
unsafe_raw <<~SVG
|
160
|
+
<svg class="h-4 w-4 sm:hidden" viewBox="0 0 20 20" fill="currentColor">
|
161
|
+
<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"/>
|
162
|
+
</svg>
|
163
|
+
SVG
|
164
|
+
# Horizontal arrow for desktop
|
165
|
+
unsafe_raw <<~SVG
|
166
|
+
<svg class="h-4 w-4 hidden sm:block" viewBox="0 0 20 20" fill="currentColor">
|
167
|
+
<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"/>
|
168
|
+
</svg>
|
169
|
+
SVG
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
if change.has_key?(:new)
|
174
|
+
div(class: "flex-1") do
|
175
|
+
div(class: "text-xs font-medium text-gray-500 uppercase tracking-wide mb-1") { "To" }
|
176
|
+
div(class: "text-sm text-gray-900 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm") do
|
177
|
+
format_value(change[:new])
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def render_item_actions(version)
|
186
|
+
# Match admin panel footer style (like in BaseCardComponent)
|
187
|
+
div(class: "px-6 py-4 bg-gray-50/30 border-t border-gray-100 flex items-center justify-between") do
|
188
|
+
div(class: "flex items-center space-x-2") do
|
189
|
+
# Button style matching admin panel (similar to table header links)
|
190
|
+
a(
|
191
|
+
href: version_diff_url(version.id),
|
192
|
+
data: {
|
193
|
+
turbo_frame: "modal"
|
194
|
+
},
|
195
|
+
class: "group hover:text-gray-900 transition-colors duration-150 text-xs font-medium text-gray-500 uppercase tracking-wide"
|
196
|
+
) do
|
197
|
+
span { "View Details" }
|
198
|
+
end
|
199
|
+
|
200
|
+
span(class: "text-gray-300") { "•" }
|
201
|
+
|
202
|
+
button(
|
203
|
+
class: "group hover:text-gray-900 transition-colors duration-150 text-xs font-medium text-gray-500 uppercase tracking-wide",
|
204
|
+
data: {
|
205
|
+
controller: "version-revert",
|
206
|
+
action: "click->version-revert#confirm",
|
207
|
+
version_revert_version_id_value: version.id,
|
208
|
+
version_revert_resource_id_value: @record.id,
|
209
|
+
version_revert_resource_name_value: @resource_class.route_key
|
210
|
+
}
|
211
|
+
) do
|
212
|
+
span { "Revert" }
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
# Version ID - using admin panel monospace style
|
217
|
+
span(class: "text-xs font-medium text-gray-500 uppercase tracking-wide") do
|
218
|
+
"##{version.id}"
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
def event_color(event)
|
224
|
+
case event
|
225
|
+
when "create" then "border-green-500"
|
226
|
+
when "update" then "border-blue-500"
|
227
|
+
when "destroy" then "border-red-500"
|
228
|
+
else "border-gray-400"
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def parse_version_object(object_yaml)
|
233
|
+
return {} if object_yaml.blank?
|
234
|
+
|
235
|
+
# Try safe loading first with common Rails classes
|
236
|
+
YAML.safe_load(
|
237
|
+
object_yaml,
|
238
|
+
permitted_classes: [Time, Date, DateTime, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, Symbol],
|
239
|
+
aliases: true
|
240
|
+
)
|
241
|
+
rescue Psych::DisallowedClass, Psych::BadAlias
|
242
|
+
# Fallback: try unsafe loading for ActiveSupport objects
|
243
|
+
begin
|
244
|
+
YAML.unsafe_load(object_yaml)
|
245
|
+
rescue
|
246
|
+
# Last resort: try to extract basic info manually
|
247
|
+
extract_basic_attributes(object_yaml)
|
248
|
+
end
|
249
|
+
rescue => e
|
250
|
+
Rails.logger.warn "Failed to parse version object: #{e.message}"
|
251
|
+
{}
|
252
|
+
end
|
253
|
+
|
254
|
+
def extract_basic_attributes(yaml_string)
|
255
|
+
# Simple regex-based extraction for basic attributes
|
256
|
+
attributes = {}
|
257
|
+
|
258
|
+
yaml_string.scan(/^(\w+):\s*(.+)$/).each do |key, value|
|
259
|
+
# Skip complex objects
|
260
|
+
next if value.include?('!ruby/object')
|
261
|
+
|
262
|
+
# Clean up the value
|
263
|
+
cleaned_value = value.strip.gsub(/^["']|["']$/, '')
|
264
|
+
attributes[key] = cleaned_value unless cleaned_value.empty?
|
265
|
+
end
|
266
|
+
|
267
|
+
attributes
|
268
|
+
rescue
|
269
|
+
{}
|
270
|
+
end
|
271
|
+
|
272
|
+
def extract_changes(version, old_attrs)
|
273
|
+
return {} unless old_attrs.is_a?(Hash)
|
274
|
+
|
275
|
+
current_item = version.item
|
276
|
+
return {} unless current_item
|
277
|
+
|
278
|
+
changes = {}
|
279
|
+
current_attrs = current_item.attributes
|
280
|
+
|
281
|
+
# Only check fields that exist in the old_attrs (were tracked by Paper Trail)
|
282
|
+
old_attrs.keys.each do |key|
|
283
|
+
next if skip_field?(key)
|
284
|
+
|
285
|
+
# Skip if field wasn't tracked in this version
|
286
|
+
next unless old_attrs.has_key?(key)
|
287
|
+
|
288
|
+
old_val = normalize_value_for_comparison(old_attrs[key])
|
289
|
+
new_val = normalize_value_for_comparison(current_attrs[key])
|
290
|
+
|
291
|
+
if old_val != new_val
|
292
|
+
changes[key] = { old: old_attrs[key], new: current_attrs[key] }
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
changes
|
297
|
+
end
|
298
|
+
|
299
|
+
def skip_field?(field)
|
300
|
+
%w[id updated_at].include?(field.to_s)
|
301
|
+
end
|
302
|
+
|
303
|
+
def normalize_value_for_comparison(value)
|
304
|
+
case value
|
305
|
+
when true, "true", "t", "1", 1
|
306
|
+
true
|
307
|
+
when false, "false", "f", "0", 0
|
308
|
+
false
|
309
|
+
when nil, ""
|
310
|
+
nil
|
311
|
+
else
|
312
|
+
value
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
def format_value(value)
|
317
|
+
case value
|
318
|
+
when Time, DateTime, ActiveSupport::TimeWithZone
|
319
|
+
value.strftime("%Y-%m-%d %H:%M")
|
320
|
+
when true
|
321
|
+
"Yes"
|
322
|
+
when false
|
323
|
+
"No"
|
324
|
+
when nil
|
325
|
+
"empty"
|
326
|
+
when String
|
327
|
+
# Try to parse as JSON for better formatting
|
328
|
+
if looks_like_json?(value)
|
329
|
+
format_json_preview(value)
|
330
|
+
else
|
331
|
+
value.to_s.truncate(30)
|
332
|
+
end
|
333
|
+
else
|
334
|
+
value.to_s.truncate(30)
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
private
|
339
|
+
|
340
|
+
def looks_like_json?(string)
|
341
|
+
return false if string.blank?
|
342
|
+
|
343
|
+
# Check for regular JSON
|
344
|
+
regular_json = string.strip.start_with?('{', '[') || string == '0' || string == 'null' || string =~ /^\d+$/
|
345
|
+
|
346
|
+
# Check for single-quoted JSON strings (like "{\"key\":\"value\"}")
|
347
|
+
quoted_json = string.strip.start_with?('"{') || string.strip.start_with?('"[')
|
348
|
+
|
349
|
+
result = regular_json || quoted_json
|
350
|
+
result
|
351
|
+
end
|
352
|
+
|
353
|
+
def format_json_preview(json_string)
|
354
|
+
return json_string if json_string.blank?
|
355
|
+
|
356
|
+
# Handle simple cases - including numeric strings that represent empty JSON
|
357
|
+
case json_string.strip
|
358
|
+
when '0', '1', '2', 'null', '""'
|
359
|
+
span(class: "text-gray-500 italic") { "empty" }
|
360
|
+
else
|
361
|
+
begin
|
362
|
+
# Try to handle quoted JSON strings
|
363
|
+
cleaned_string = json_string
|
364
|
+
if json_string.strip.start_with?('"{') || json_string.strip.start_with?('"[')
|
365
|
+
# Remove outer quotes and parse as JSON
|
366
|
+
cleaned_string = JSON.parse(json_string)
|
367
|
+
end
|
368
|
+
|
369
|
+
parsed = JSON.parse(cleaned_string)
|
370
|
+
if parsed.is_a?(Hash) && parsed.empty?
|
371
|
+
span(class: "text-gray-500 italic") { "empty object" }
|
372
|
+
elsif parsed.is_a?(Array) && parsed.empty?
|
373
|
+
span(class: "text-gray-500 italic") { "empty array" }
|
374
|
+
elsif parsed.is_a?(Hash)
|
375
|
+
# Show key count for objects
|
376
|
+
key_count = parsed.keys.size
|
377
|
+
span(class: "text-gray-700") { "#{key_count} #{'key'.pluralize(key_count)}" }
|
378
|
+
elsif parsed.is_a?(Array)
|
379
|
+
# Show item count for arrays
|
380
|
+
item_count = parsed.size
|
381
|
+
span(class: "text-gray-700") { "#{item_count} #{'item'.pluralize(item_count)}" }
|
382
|
+
else
|
383
|
+
# Simple value
|
384
|
+
parsed.to_s.truncate(30)
|
385
|
+
end
|
386
|
+
rescue JSON::ParserError
|
387
|
+
# Not valid JSON, truncate as string
|
388
|
+
json_string.truncate(30)
|
389
|
+
end
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
# Event colors matching admin panel's subtle approach
|
394
|
+
def event_bg_color(event)
|
395
|
+
case event
|
396
|
+
when "create" then "bg-blue-500"
|
397
|
+
when "update" then "bg-gray-400"
|
398
|
+
when "destroy" then "bg-red-500"
|
399
|
+
else "bg-gray-300"
|
400
|
+
end
|
401
|
+
end
|
402
|
+
|
403
|
+
def version_diff_url(version_id)
|
404
|
+
return '#' unless @resource_class && @record
|
405
|
+
|
406
|
+
easy_admin_url_helpers.resource_version_diff_path(
|
407
|
+
@resource_class.route_key,
|
408
|
+
@record.id,
|
409
|
+
version_id
|
410
|
+
)
|
411
|
+
end
|
412
|
+
|
413
|
+
end
|
414
|
+
end
|
415
|
+
end
|