spree_admin 5.2.0.rc3 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/spree/admin/application.scss +1 -1
  3. data/app/assets/stylesheets/spree/admin/components/_dropdowns.scss +2 -0
  4. data/app/assets/stylesheets/spree/admin/components/_main.scss +2 -233
  5. data/app/assets/stylesheets/spree/admin/components/_sidebar.scss +693 -0
  6. data/app/assets/stylesheets/spree/admin/global/_variables.scss +1 -0
  7. data/app/assets/stylesheets/spree/admin/shared/_base.scss +7 -1
  8. data/app/assets/stylesheets/spree/admin/views/_dashboard.scss +14 -0
  9. data/app/controllers/spree/admin/integrations_controller.rb +1 -1
  10. data/app/controllers/spree/admin/metafields_controller.rb +1 -1
  11. data/app/controllers/spree/admin/page_blocks_controller.rb +1 -1
  12. data/app/controllers/spree/admin/payment_methods_controller.rb +1 -1
  13. data/app/controllers/spree/admin/promotion_actions_controller.rb +1 -1
  14. data/app/controllers/spree/admin/promotion_rules_controller.rb +1 -1
  15. data/app/controllers/spree/admin/promotions_controller.rb +1 -1
  16. data/app/controllers/spree/admin/reports_controller.rb +1 -1
  17. data/app/controllers/spree/admin/taxons_controller.rb +1 -1
  18. data/app/controllers/spree/admin/translations_controller.rb +1 -1
  19. data/app/helpers/spree/admin/base_helper.rb +1 -1
  20. data/app/helpers/spree/admin/drawer_helper.rb +6 -6
  21. data/app/helpers/spree/admin/dropdown_helper.rb +10 -2
  22. data/app/helpers/spree/admin/modal_helper.rb +2 -0
  23. data/app/helpers/spree/admin/navigation_helper.rb +46 -3
  24. data/app/helpers/spree/admin/orders_filters_helper.rb +1 -0
  25. data/app/helpers/spree/admin/promotion_actions_helper.rb +1 -1
  26. data/app/helpers/spree/admin/promotion_rules_helper.rb +1 -1
  27. data/app/helpers/spree/admin/translations_helper.rb +1 -1
  28. data/app/javascript/spree/admin/application.js +2 -1
  29. data/app/javascript/spree/admin/controllers/dropdown_controller.js +85 -14
  30. data/app/javascript/spree/admin/controllers/sidebar_controller.js +231 -0
  31. data/app/javascript/spree/admin/controllers/tooltip_controller.js +84 -31
  32. data/app/models/spree/admin/form_builder.rb +74 -16
  33. data/app/models/spree/admin/navigation/builder.rb +82 -0
  34. data/app/models/spree/admin/navigation/item.rb +177 -0
  35. data/app/models/spree/admin/navigation.rb +193 -0
  36. data/app/views/layouts/spree/admin.html.erb +1 -1
  37. data/app/views/spree/admin/assets/edit.html.erb +1 -1
  38. data/app/views/spree/admin/custom_domains/_form.html.erb +2 -14
  39. data/app/views/spree/admin/gift_cards/_filters.html.erb +26 -18
  40. data/app/views/spree/admin/gift_cards/index.html.erb +1 -1
  41. data/app/views/spree/admin/orders/_customer.html.erb +2 -2
  42. data/app/views/spree/admin/orders/_filters.html.erb +34 -25
  43. data/app/views/spree/admin/orders/_table_filter_dropdown.html.erb +1 -1
  44. data/app/views/spree/admin/page_blocks/forms/_image.html.erb +2 -5
  45. data/app/views/spree/admin/page_links/_form.html.erb +4 -13
  46. data/app/views/spree/admin/page_sections/forms/_header.html.erb +0 -2
  47. data/app/views/spree/admin/payments/_payment.html.erb +7 -0
  48. data/app/views/spree/admin/posts/_form.html.erb +1 -4
  49. data/app/views/spree/admin/posts/filters.html.erb +18 -8
  50. data/app/views/spree/admin/products/_bulk_operations.html.erb +1 -1
  51. data/app/views/spree/admin/products/_filters.html.erb +17 -6
  52. data/app/views/spree/admin/products/_table_filter_dropdown.html.erb +1 -1
  53. data/app/views/spree/admin/profile/edit.html.erb +9 -61
  54. data/app/views/spree/admin/promotions/_filters.html.erb +23 -13
  55. data/app/views/spree/admin/promotions/_table_filter_dropdown.html.erb +1 -1
  56. data/app/views/spree/admin/promotions/form/_settings.html.erb +2 -13
  57. data/app/views/spree/admin/refunds/_form.html.erb +1 -9
  58. data/app/views/spree/admin/return_authorizations/filters.html.erb +1 -1
  59. data/app/views/spree/admin/shared/_audit_nav.html.erb +2 -0
  60. data/app/views/spree/admin/shared/_calendar_range_picker.html.erb +2 -2
  61. data/app/views/spree/admin/shared/_content_header.html.erb +1 -1
  62. data/app/views/spree/admin/shared/_developers_nav.html.erb +2 -4
  63. data/app/views/spree/admin/shared/_header.html.erb +5 -7
  64. data/app/views/spree/admin/shared/_navigation.html.erb +5 -0
  65. data/app/views/spree/admin/shared/_navigation_item.html.erb +64 -0
  66. data/app/views/spree/admin/shared/_new_item_dropdown.html.erb +1 -1
  67. data/app/views/spree/admin/shared/_page_section_image.html.erb +2 -5
  68. data/app/views/spree/admin/shared/_page_section_logo.html.erb +1 -1
  69. data/app/views/spree/admin/shared/_returns_and_refunds_nav.html.erb +2 -3
  70. data/app/views/spree/admin/shared/_shipping_nav.html.erb +3 -2
  71. data/app/views/spree/admin/shared/_sidebar.html.erb +33 -7
  72. data/app/views/spree/admin/shared/_stock_nav.html.erb +6 -3
  73. data/app/views/spree/admin/shared/_tax_nav.html.erb +1 -2
  74. data/app/views/spree/admin/shared/_team_nav.html.erb +2 -3
  75. data/app/views/spree/admin/shared/_user_dropdown.html.erb +26 -17
  76. data/app/views/spree/admin/shared/sidebar/_customers_nav.html.erb +7 -0
  77. data/app/views/spree/admin/shared/sidebar/_orders_nav.html.erb +22 -2
  78. data/app/views/spree/admin/shared/sidebar/_products_nav.html.erb +21 -0
  79. data/app/views/spree/admin/shared/sidebar/_promotions_nav.html.erb +8 -0
  80. data/app/views/spree/admin/shared/sidebar/_returns_nav.html.erb +12 -0
  81. data/app/views/spree/admin/shared/sidebar/_store_dropdown.html.erb +3 -1
  82. data/app/views/spree/admin/shared/sidebar/_store_nav.html.erb +15 -1
  83. data/app/views/spree/admin/shared/sidebar/_storefront_nav.html.erb +25 -3
  84. data/app/views/spree/admin/shared/sortable_tree/_taxonomy.html.erb +1 -1
  85. data/app/views/spree/admin/stock_items/_filters.html.erb +18 -8
  86. data/app/views/spree/admin/stock_locations/_table_row.html.erb +1 -1
  87. data/app/views/spree/admin/stock_transfers/_filters.html.erb +19 -9
  88. data/app/views/spree/admin/storefront/edit.html.erb +2 -14
  89. data/app/views/spree/admin/stores/form/_basic.html.erb +2 -8
  90. data/app/views/spree/admin/stores/form/_emails.html.erb +1 -1
  91. data/app/views/spree/admin/tax_rates/_form.html.erb +1 -10
  92. data/app/views/spree/admin/taxons/_form.html.erb +2 -9
  93. data/app/views/spree/admin/themes/_theme.html.erb +1 -1
  94. data/app/views/spree/admin/translations/translation_rows/_permalink_field_row.html.erb +1 -12
  95. data/app/views/spree/admin/users/_filters.html.erb +23 -13
  96. data/config/initializers/spree_admin_navigation.rb +510 -0
  97. data/config/locales/en.yml +4 -0
  98. data/lib/generators/spree/admin/scaffold/templates/controller.rb.tt +3 -1
  99. data/lib/generators/spree/admin/scaffold/templates/views/_table_row.html.erb.tt +8 -6
  100. data/lib/spree/admin/engine.rb +64 -2
  101. data/lib/spree/admin/runtime_configuration.rb +1 -0
  102. data/lib/spree/admin.rb +20 -0
  103. metadata +17 -15
  104. data/app/assets/stylesheets/spree/admin/components/_offcanvas.scss +0 -26
  105. data/app/javascript/spree/admin/helpers/canvas.js +0 -29
  106. data/app/views/spree/admin/shared/_offcanvas_nav.html.erb +0 -12
@@ -0,0 +1,231 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import {
3
+ computePosition,
4
+ flip,
5
+ shift,
6
+ offset,
7
+ autoUpdate,
8
+ } from "@floating-ui/dom";
9
+
10
+ export default class extends Controller {
11
+ static targets = ["desktop", "mobile"];
12
+
13
+ static values = {
14
+ storageKey: { type: String, default: "spree_admin_sidebar_open" },
15
+ };
16
+
17
+ connect() {
18
+ // Initialize dropdown tracking
19
+ this.activeDropdown = null
20
+ this.dropdownCleanup = null
21
+ this.submenuHoverHandlers = new Map()
22
+ this.hideTimeout = null
23
+
24
+ // Disable transitions during initial load to prevent animation flash
25
+ this.element.classList.add("sidebar-no-transition");
26
+
27
+ // Restore saved state for desktop sidebar (default is open/expanded)
28
+ const savedState = localStorage.getItem(this.storageKeyValue);
29
+
30
+ // Only collapse if explicitly saved as false
31
+ if (savedState === "false") {
32
+ this.element.classList.add("sidebar-collapsed");
33
+ this.setupCollapsedSubmenuHandlers();
34
+ } else {
35
+ // Explicitly ensure it's not collapsed (default is expanded)
36
+ this.element.classList.remove("sidebar-collapsed");
37
+ }
38
+
39
+ // Re-enable transitions after initial state is set
40
+ requestAnimationFrame(() => {
41
+ this.element.classList.remove("sidebar-no-transition");
42
+ });
43
+ }
44
+
45
+ disconnect() {
46
+ // Clear any pending hide timeout
47
+ if (this.hideTimeout) {
48
+ clearTimeout(this.hideTimeout);
49
+ this.hideTimeout = null;
50
+ }
51
+
52
+ this.cleanupSubmenuHandlers();
53
+ if (this.dropdownCleanup) {
54
+ this.dropdownCleanup();
55
+ this.dropdownCleanup = null;
56
+ }
57
+ }
58
+
59
+ toggle() {
60
+ this.element.classList.toggle("sidebar-collapsed");
61
+ const isCollapsed = this.element.classList.contains("sidebar-collapsed");
62
+ localStorage.setItem(this.storageKeyValue, String(!isCollapsed));
63
+
64
+ // Setup or cleanup submenu handlers based on state
65
+ if (isCollapsed) {
66
+ this.setupCollapsedSubmenuHandlers();
67
+ } else {
68
+ this.cleanupSubmenuHandlers();
69
+ }
70
+ }
71
+
72
+ openMobile() {
73
+ if (this.hasMobileTarget) {
74
+ this.mobileTarget.classList.add("sidebar-mobile-open");
75
+ }
76
+ }
77
+
78
+ closeMobile() {
79
+ if (this.hasMobileTarget) {
80
+ this.mobileTarget.classList.remove("sidebar-mobile-open");
81
+ }
82
+ }
83
+
84
+ setupCollapsedSubmenuHandlers() {
85
+ const sidebar = document.querySelector('#main-sidebar');
86
+ if (!sidebar) return;
87
+
88
+ // Find all dropdown submenus
89
+ const dropdowns = sidebar.querySelectorAll('.nav-submenu-dropdown');
90
+
91
+ dropdowns.forEach(dropdown => {
92
+ // Find the associated nav-item (previous sibling, skipping the regular nav-submenu)
93
+ let navItem = dropdown.previousElementSibling;
94
+
95
+ // Skip over the regular .nav-submenu to get to the .nav-item
96
+ while (navItem && navItem.classList.contains('nav-submenu')) {
97
+ navItem = navItem.previousElementSibling;
98
+ }
99
+
100
+ if (!navItem || !navItem.classList.contains('nav-item')) return;
101
+
102
+ const showHandler = () => this.showSubmenuFloating(navItem, dropdown);
103
+ const hideHandler = () => this.scheduleHideSubmenu();
104
+
105
+ navItem.addEventListener('mouseenter', showHandler);
106
+ navItem.addEventListener('mouseleave', hideHandler);
107
+ dropdown.addEventListener('mouseenter', showHandler);
108
+ dropdown.addEventListener('mouseleave', hideHandler);
109
+
110
+ this.submenuHoverHandlers.set(navItem, { showHandler, hideHandler, dropdown });
111
+ });
112
+ }
113
+
114
+ cleanupSubmenuHandlers() {
115
+ this.submenuHoverHandlers.forEach(({ showHandler, hideHandler, dropdown }, navItem) => {
116
+ navItem.removeEventListener('mouseenter', showHandler);
117
+ navItem.removeEventListener('mouseleave', hideHandler);
118
+ dropdown.removeEventListener('mouseenter', showHandler);
119
+ dropdown.removeEventListener('mouseleave', hideHandler);
120
+ });
121
+ this.submenuHoverHandlers.clear();
122
+
123
+ // Hide active dropdown if any
124
+ if (this.activeDropdown) {
125
+ this.hideSubmenuFloating();
126
+ }
127
+ }
128
+
129
+ showSubmenuFloating(navItem, dropdown) {
130
+ // Cancel any pending hide
131
+ if (this.hideTimeout) {
132
+ clearTimeout(this.hideTimeout);
133
+ this.hideTimeout = null;
134
+ }
135
+
136
+ // If showing same dropdown, return
137
+ if (this.activeDropdown === dropdown) {
138
+ return;
139
+ }
140
+
141
+ // Hide any currently active dropdown
142
+ if (this.activeDropdown) {
143
+ this.hideSubmenuFloating();
144
+ }
145
+
146
+ // Store reference
147
+ this.activeDropdown = dropdown;
148
+
149
+ // Move dropdown to body for proper positioning (to avoid sidebar overflow clipping)
150
+ if (dropdown.parentNode !== document.body) {
151
+ dropdown._originalParent = dropdown.parentNode;
152
+ dropdown._originalNextSibling = dropdown.nextSibling;
153
+ document.body.appendChild(dropdown);
154
+ }
155
+
156
+ // Show dropdown
157
+ dropdown.classList.remove('d-none');
158
+
159
+ // Style dropdown items
160
+ dropdown.querySelectorAll('.nav-link').forEach((item, index) => {
161
+ item.classList.add('dropdown-item');
162
+ if (index > 0) {
163
+ item.classList.add('mt-1');
164
+ }
165
+ });
166
+
167
+ // Position using Floating UI
168
+ if (this.dropdownCleanup) {
169
+ this.dropdownCleanup();
170
+ }
171
+
172
+ this.dropdownCleanup = autoUpdate(navItem, dropdown, () => {
173
+ computePosition(navItem, dropdown, {
174
+ placement: 'right-start',
175
+ middleware: [
176
+ offset(8),
177
+ flip(),
178
+ shift({ padding: 8 }),
179
+ ],
180
+ }).then(({ x, y }) => {
181
+ Object.assign(dropdown.style, {
182
+ left: `${x}px`,
183
+ top: `${y}px`,
184
+ });
185
+ });
186
+ });
187
+ }
188
+
189
+ scheduleHideSubmenu() {
190
+ // Clear any existing timeout
191
+ if (this.hideTimeout) {
192
+ clearTimeout(this.hideTimeout);
193
+ }
194
+
195
+ // Schedule hide with delay to allow mouse to move to submenu
196
+ this.hideTimeout = setTimeout(() => {
197
+ this.hideSubmenuFloating();
198
+ this.hideTimeout = null;
199
+ }, 150); // 150ms grace period
200
+ }
201
+
202
+ hideSubmenuFloating() {
203
+ if (!this.activeDropdown) return;
204
+
205
+ // Stop auto-update
206
+ if (this.dropdownCleanup) {
207
+ this.dropdownCleanup();
208
+ this.dropdownCleanup = null;
209
+ }
210
+
211
+ // Hide dropdown
212
+ this.activeDropdown.classList.add('d-none');
213
+
214
+ // Restore dropdown to original position in sidebar
215
+ if (this.activeDropdown._originalParent) {
216
+ if (this.activeDropdown._originalNextSibling) {
217
+ this.activeDropdown._originalParent.insertBefore(
218
+ this.activeDropdown,
219
+ this.activeDropdown._originalNextSibling
220
+ );
221
+ } else {
222
+ this.activeDropdown._originalParent.appendChild(this.activeDropdown);
223
+ }
224
+ this.activeDropdown._originalParent = null;
225
+ this.activeDropdown._originalNextSibling = null;
226
+ }
227
+
228
+ // Clear reference
229
+ this.activeDropdown = null;
230
+ }
231
+ }
@@ -8,7 +8,6 @@ import {
8
8
  import { Controller } from "@hotwired/stimulus"
9
9
 
10
10
  export default class extends Controller {
11
- static targets = ["tooltip"]
12
11
  static values = {
13
12
  placement: { type: String, default: "top" },
14
13
  offset: { type: Number, default: 10 },
@@ -17,18 +16,34 @@ export default class extends Controller {
17
16
  }
18
17
 
19
18
  connect() {
19
+ // Find tooltip element by CSS class instead of Stimulus target
20
+ this.tooltip = this.element.querySelector('.tooltip-container')
21
+
22
+ // Early return if no tooltip element exists
23
+ if (!this.tooltip) {
24
+ return
25
+ }
26
+
20
27
  this._cleanup = null
21
28
  this.boundUpdate = this.update.bind(this)
22
29
  this._originalWidth = null
23
30
  this._isShown = false
31
+ this._originalParent = null
32
+ this._originalNextSibling = null
33
+ this._movedToBody = false
24
34
  this.startAutoUpdate()
25
35
  this.addEventListeners()
26
36
  this.prepareTooltip()
27
37
  }
28
38
 
29
39
  disconnect() {
40
+ if (!this.tooltip) {
41
+ return
42
+ }
43
+
30
44
  this.removeEventListeners()
31
45
  this.stopAutoUpdate()
46
+ this.restoreTooltipPosition()
32
47
  this.resetTooltip()
33
48
  }
34
49
 
@@ -44,43 +59,75 @@ export default class extends Controller {
44
59
 
45
60
  prepareTooltip() {
46
61
  // Ensure tooltip is rendered offscreen but measurable
47
- if (this.tooltipTarget) {
62
+ if (this.tooltip) {
48
63
  // Save original display
49
- this._originalDisplay = this.tooltipTarget.style.display
64
+ this._originalDisplay = this.tooltip.style.display
50
65
  // Temporarily show tooltip offscreen to measure size
51
- this.tooltipTarget.style.visibility = "hidden"
52
- this.tooltipTarget.style.display = "block"
53
- this.tooltipTarget.style.left = "-9999px"
54
- this.tooltipTarget.style.top = "-9999px"
66
+ this.tooltip.style.visibility = "hidden"
67
+ this.tooltip.style.display = "block"
68
+ this.tooltip.style.left = "-9999px"
69
+ this.tooltip.style.top = "-9999px"
55
70
  // Force reflow and measure
56
- this._originalWidth = this.tooltipTarget.offsetWidth + 10
71
+ this._originalWidth = this.tooltip.offsetWidth + 10
57
72
  // Height is now dynamic, so we do not set or store it
58
- this.tooltipTarget.style.width = `${this._originalWidth}px`
59
- this.tooltipTarget.style.height = "" // Remove any fixed height
73
+ this.tooltip.style.width = `${this._originalWidth}px`
74
+ this.tooltip.style.height = "" // Remove any fixed height
60
75
  // Hide again
61
- this.tooltipTarget.style.display = "none"
62
- this.tooltipTarget.style.visibility = ""
63
- this.tooltipTarget.style.left = ""
64
- this.tooltipTarget.style.top = ""
76
+ this.tooltip.style.display = "none"
77
+ this.tooltip.style.visibility = ""
78
+ this.tooltip.style.left = ""
79
+ this.tooltip.style.top = ""
65
80
  }
66
81
  }
67
82
 
68
83
  resetTooltip() {
69
- if (this.tooltipTarget) {
70
- this.tooltipTarget.style.width = ""
71
- this.tooltipTarget.style.height = ""
72
- this.tooltipTarget.style.display = this._originalDisplay || ""
84
+ if (this.tooltip) {
85
+ this.tooltip.style.width = ""
86
+ this.tooltip.style.height = ""
87
+ this.tooltip.style.display = this._originalDisplay || ""
88
+ }
89
+ }
90
+
91
+ moveTooltipToBody() {
92
+ if (this.tooltip && this.tooltip.parentNode !== document.body) {
93
+ // Save original position for restoration
94
+ this._originalParent = this.tooltip.parentNode
95
+ this._originalNextSibling = this.tooltip.nextSibling
96
+
97
+ // Move tooltip to body to prevent clipping by sidebar overflow
98
+ document.body.appendChild(this.tooltip)
99
+ }
100
+ }
101
+
102
+ restoreTooltipPosition() {
103
+ if (this.tooltip && this._originalParent) {
104
+ // Restore tooltip to original position
105
+ if (this._originalNextSibling) {
106
+ this._originalParent.insertBefore(this.tooltip, this._originalNextSibling)
107
+ } else {
108
+ this._originalParent.appendChild(this.tooltip)
109
+ }
110
+ this._originalParent = null
111
+ this._originalNextSibling = null
73
112
  }
74
113
  }
75
114
 
76
115
  show = () => {
116
+ if (!this.tooltip) return
117
+
77
118
  if (!this._isShown) {
78
- this.tooltipTarget.style.display = "block"
79
- this.tooltipTarget.style.visibility = "visible"
119
+ // Move to body on first show
120
+ if (!this._movedToBody) {
121
+ this.moveTooltipToBody()
122
+ this._movedToBody = true
123
+ }
124
+
125
+ this.tooltip.style.display = "block"
126
+ this.tooltip.style.visibility = "visible"
80
127
  // Set explicit width, but let height be dynamic
81
128
  if (this._originalWidth) {
82
- this.tooltipTarget.style.width = `${this._originalWidth}px`
83
- this.tooltipTarget.style.height = ""
129
+ this.tooltip.style.width = `${this._originalWidth}px`
130
+ this.tooltip.style.height = ""
84
131
  }
85
132
  this.update() // Ensure immediate update when shown
86
133
  this._isShown = true
@@ -88,19 +135,21 @@ export default class extends Controller {
88
135
  }
89
136
 
90
137
  hide = () => {
138
+ if (!this.tooltip) return
139
+
91
140
  if (this._isShown) {
92
- this.tooltipTarget.style.display = "none"
93
- this.tooltipTarget.style.visibility = ""
141
+ this.tooltip.style.display = "none"
142
+ this.tooltip.style.visibility = ""
94
143
  // Keep width set to prevent flicker if quickly re-hovered
95
144
  this._isShown = false
96
145
  }
97
146
  }
98
147
 
99
148
  startAutoUpdate() {
100
- if (!this._cleanup) {
149
+ if (!this._cleanup && this.tooltip) {
101
150
  this._cleanup = autoUpdate(
102
151
  this.element,
103
- this.tooltipTarget,
152
+ this.tooltip,
104
153
  this.boundUpdate,
105
154
  )
106
155
  }
@@ -114,8 +163,10 @@ export default class extends Controller {
114
163
  }
115
164
 
116
165
  update() {
166
+ if (!this.tooltip) return
167
+
117
168
  // Update position even if not visible, to ensure correct positioning when shown
118
- computePosition(this.element, this.tooltipTarget, {
169
+ computePosition(this.element, this.tooltip, {
119
170
  placement: this.placementValue,
120
171
  middleware: [
121
172
  offset({
@@ -127,10 +178,12 @@ export default class extends Controller {
127
178
  shift({ padding: 5 }),
128
179
  ],
129
180
  }).then(({ x, y }) => {
130
- Object.assign(this.tooltipTarget.style, {
131
- left: `${x}px`,
132
- top: `${y}px`,
133
- })
181
+ if (this.tooltip) {
182
+ Object.assign(this.tooltip.style, {
183
+ left: `${x}px`,
184
+ top: `${y}px`,
185
+ })
186
+ }
134
187
  })
135
188
  }
136
189
  }
@@ -24,13 +24,18 @@ module Spree
24
24
  # - :required [Boolean] whether field is required
25
25
  # - :help [String] help text to display below the field
26
26
  # - :help_bubble [String] help bubble text
27
+ # - :prepend [String] text to prepend before the input field
28
+ # - :append [String] text to append after the input field
27
29
  # @return [String] HTML string containing the complete form group
28
30
  def spree_text_field(method, options = {})
29
31
  options[:class] ||= 'form-control'
32
+ prepend = options.delete(:prepend)
33
+ append = options.delete(:append)
34
+
30
35
  @template.content_tag(:div, class: 'form-group') do
31
36
  @template.label(@object_name, method, get_label(method, options)) +
32
- @template.text_field(@object_name, method, objectify_options(options)) +
33
- @template.error_message_on(@object_name, method) + field_help(method, options.merge(class: 'form-text mt-2'))
37
+ wrap_with_input_group(@template.text_field(@object_name, method, objectify_options(options)), prepend, append) +
38
+ @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
34
39
  end
35
40
  end
36
41
 
@@ -41,10 +46,13 @@ module Spree
41
46
  # @return [String] HTML string containing the complete form group
42
47
  def spree_number_field(method, options = {})
43
48
  options[:class] ||= 'form-control'
49
+ prepend = options.delete(:prepend)
50
+ append = options.delete(:append)
51
+
44
52
  @template.content_tag(:div, class: 'form-group') do
45
53
  @template.label(@object_name, method, get_label(method, options)) +
46
- @template.number_field(@object_name, method, objectify_options(options)) +
47
- @template.error_message_on(@object_name, method) + field_help(method, options.merge(class: 'form-text mt-2'))
54
+ wrap_with_input_group(@template.number_field(@object_name, method, objectify_options(options)), prepend, append) +
55
+ @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
48
56
  end
49
57
  end
50
58
 
@@ -58,7 +66,7 @@ module Spree
58
66
  @template.content_tag(:div, class: 'form-group') do
59
67
  @template.label(@object_name, method, get_label(method, options)) +
60
68
  @template.email_field(@object_name, method, objectify_options(options)) +
61
- @template.error_message_on(@object_name, method) + field_help(method, options.merge(class: 'form-text mt-2'))
69
+ @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
62
70
  end
63
71
  end
64
72
 
@@ -72,7 +80,7 @@ module Spree
72
80
  @template.content_tag(:div, class: 'form-group') do
73
81
  @template.label(@object_name, method, get_label(method, options)) +
74
82
  @template.date_field(@object_name, method, objectify_options(options)) +
75
- @template.error_message_on(@object_name, method) + field_help(method, options.merge(class: 'form-text mt-2'))
83
+ @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
76
84
  end
77
85
  end
78
86
 
@@ -86,7 +94,7 @@ module Spree
86
94
  @template.content_tag(:div, class: 'form-group') do
87
95
  @template.label(@object_name, method, get_label(method, options)) +
88
96
  @template.datetime_field(@object_name, method, objectify_options(options)) +
89
- @template.error_message_on(@object_name, method) + field_help(method, options.merge(class: 'form-text mt-2'))
97
+ @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
90
98
  end
91
99
  end
92
100
 
@@ -107,7 +115,7 @@ module Spree
107
115
 
108
116
  @template.label(@object_name, method, get_label(method, options)) +
109
117
  @template.text_area(@object_name, method, objectify_options(options)) +
110
- @template.error_message_on(@object_name, method) + field_help(method, options.merge(class: 'form-text mt-2'))
118
+ @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
111
119
  end
112
120
  end
113
121
 
@@ -122,7 +130,7 @@ module Spree
122
130
  @template.content_tag(:div, class: 'trix-container') do
123
131
  @template.rich_text_area(@object_name, method, objectify_options(options))
124
132
  end +
125
- @template.error_message_on(@object_name, method) + field_help(method, options.merge(class: 'form-text mt-2'))
133
+ @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
126
134
  end
127
135
  end
128
136
 
@@ -147,7 +155,7 @@ module Spree
147
155
  @template.content_tag(:div, class: 'form-group') do
148
156
  @template.label(@object_name, method, get_label(method, options)) +
149
157
  @template.select(@object_name, method, choices, objectify_options(options), html_options, &block) +
150
- @template.error_message_on(@object_name, method) + field_help(method, options.merge(class: 'form-text mt-2'))
158
+ @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
151
159
  end
152
160
  end
153
161
 
@@ -173,7 +181,7 @@ module Spree
173
181
  @template.content_tag(:div, class: 'form-group') do
174
182
  @template.label(@object_name, method, get_label(method, options)) +
175
183
  @template.collection_select(@object_name, method, collection, value_method, text_method, objectify_options(options), html_options) +
176
- @template.error_message_on(@object_name, method) + field_help(method, options.merge(class: 'form-text mt-2'))
184
+ @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
177
185
  end
178
186
  end
179
187
 
@@ -187,7 +195,7 @@ module Spree
187
195
  @template.content_tag(:div, class: 'custom-control custom-checkbox') do
188
196
  @template.check_box(@object_name, method, objectify_options(options.merge(class: 'custom-control-input'))) +
189
197
  @template.label(@object_name, method, get_label(method, options), class: 'custom-control-label')
190
- end + @template.error_message_on(@object_name, method) + field_help(method, options.merge!(class: 'form-text mt-2 ml-4'))
198
+ end + @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge!(class: 'form-text mt-2 ml-4'))
191
199
  end
192
200
  end
193
201
 
@@ -204,23 +212,43 @@ module Spree
204
212
  @template.content_tag(:div, class: 'custom-control custom-radio') do
205
213
  @template.radio_button(@object_name, method, tag_value, objectify_options(options.merge(class: 'custom-control-input'))) +
206
214
  @template.label(@object_name, method, get_label(method, options), class: 'custom-control-label', for: options[:id])
207
- end + @template.error_message_on(@object_name, method) + field_help(method, options.merge(class: 'form-text mt-2'))
215
+ end + @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
208
216
  end
209
217
  end
210
218
 
219
+ # Create a direct file upload field with Spree form styling
220
+ #
221
+ # @param method [Symbol] the field name
222
+ # @param options [Hash] field options
223
+ # @option options [Boolean] :crop whether to crop the image
224
+ # @option options [Boolean] :auto_submit whether to auto-submit the form when the file is uploaded
225
+ # @option options [Boolean] :can_delete whether to show the delete button
226
+ # @option options [Boolean] :inline whether to display the uploader inline
227
+ # @option options [Integer] :height the height of the uploader
228
+ # @option options [Integer] :width the width of the uploader
229
+ # @option options [Array] :allowed_file_types the allowed file types, defaults to image types
230
+ # @return [String] HTML string containing the complete form group with direct file upload field
231
+ def spree_file_field(method, options = {})
232
+ @template.content_tag(:div, class: 'form-group') do
233
+ @template.label(@object_name, method, get_label(method, options)) +
234
+ @template.render('active_storage/upload_form', form: self, field_name: method, **options) +
235
+ @template.error_message_on(@object_name, method) + spree_field_help(method, options.merge(class: 'form-text mt-2'))
236
+ end
237
+ end
238
+
239
+ private
240
+
211
241
  # Generate help text for a field
212
242
  #
213
243
  # @param _method [Symbol] the field name (unused but kept for consistency)
214
244
  # @param options [Hash] field options
215
245
  # @option options [String] :help help text to display
216
246
  # @return [String] HTML string containing the help text or empty string
217
- def field_help(_method, options = {})
247
+ def spree_field_help(_method, options = {})
218
248
  options[:class] ||= 'form-text mt-2'
219
249
  @template.content_tag(:span, options[:help], class: options[:class])
220
250
  end
221
251
 
222
- private
223
-
224
252
  # Generate the label for a field with required indicator and help bubble
225
253
  #
226
254
  # @param method [Symbol] the field name
@@ -246,6 +274,36 @@ module Spree
246
274
 
247
275
  @template.raw(translated_label + required_label + help_bubble)
248
276
  end
277
+
278
+ # Wrap a field with an input group if prepend or append is specified
279
+ #
280
+ # @param field_html [String] the HTML for the field
281
+ # @param prepend [String, nil] text to prepend before the input field
282
+ # @param append [String, nil] text to append after the input field
283
+ # @return [String] HTML string with input group wrapper or the original field
284
+ def wrap_with_input_group(field_html, prepend = nil, append = nil)
285
+ return field_html if prepend.nil? && append.nil?
286
+
287
+ @template.content_tag(:div, class: 'input-group') do
288
+ prepend_html = if prepend.present?
289
+ @template.content_tag(:div, class: 'input-group-prepend') do
290
+ @template.content_tag(:span, prepend, class: 'input-group-text')
291
+ end
292
+ else
293
+ ''.html_safe
294
+ end
295
+
296
+ append_html = if append.present?
297
+ @template.content_tag(:div, class: 'input-group-append') do
298
+ @template.content_tag(:span, append, class: 'input-group-text')
299
+ end
300
+ else
301
+ ''.html_safe
302
+ end
303
+
304
+ prepend_html + field_html + append_html
305
+ end
306
+ end
249
307
  end
250
308
  end
251
309
  end