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.
- 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/fields/base_component.rb +23 -0
- data/app/components/easy_admin/fields/form/boolean_component.rb +1 -0
- data/app/components/easy_admin/fields/form/date_component.rb +3 -2
- data/app/components/easy_admin/fields/form/datetime_component.rb +3 -2
- data/app/components/easy_admin/fields/form/email_component.rb +3 -2
- data/app/components/easy_admin/fields/form/file_component.rb +6 -7
- data/app/components/easy_admin/fields/form/number_component.rb +3 -2
- data/app/components/easy_admin/fields/form/select_component.rb +5 -3
- data/app/components/easy_admin/fields/form/text_component.rb +3 -2
- data/app/components/easy_admin/fields/form/textarea_component.rb +3 -2
- 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 +5 -3
- 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
@@ -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
|
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
|
116
|
-
|
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
|
-
|
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
|