easy-admin-rails 0.1.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 (203) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/builds/easy_admin.base.js +43505 -0
  6. data/app/assets/builds/easy_admin.base.js.map +7 -0
  7. data/app/assets/builds/easy_admin.css +6141 -0
  8. data/app/assets/config/easy_admin_manifest.js +1 -0
  9. data/app/assets/images/jsoneditor-icons.svg +749 -0
  10. data/app/assets/stylesheets/easy_admin/application.tailwind.css +390 -0
  11. data/app/components/easy_admin/base_component.rb +35 -0
  12. data/app/components/easy_admin/batch_action_bar_component.rb +125 -0
  13. data/app/components/easy_admin/batch_action_form_component.rb +124 -0
  14. data/app/components/easy_admin/combined_filters_component.rb +232 -0
  15. data/app/components/easy_admin/confirmation_modal_component.rb +61 -0
  16. data/app/components/easy_admin/context_menu_component.rb +161 -0
  17. data/app/components/easy_admin/dashboards/base_card_component.rb +152 -0
  18. data/app/components/easy_admin/dashboards/card_error_component.rb +23 -0
  19. data/app/components/easy_admin/dashboards/card_factory.rb +90 -0
  20. data/app/components/easy_admin/dashboards/card_stream_component.rb +22 -0
  21. data/app/components/easy_admin/dashboards/cards/base_card_component.rb +54 -0
  22. data/app/components/easy_admin/dashboards/cards/chart_card_component.rb +175 -0
  23. data/app/components/easy_admin/dashboards/cards/custom_card_component.rb +50 -0
  24. data/app/components/easy_admin/dashboards/cards/metric_card_component.rb +164 -0
  25. data/app/components/easy_admin/dashboards/cards/table_card_component.rb +148 -0
  26. data/app/components/easy_admin/dashboards/chart_card_component.rb +44 -0
  27. data/app/components/easy_admin/dashboards/metric_card_component.rb +56 -0
  28. data/app/components/easy_admin/dashboards/refresh_stream_component.rb +279 -0
  29. data/app/components/easy_admin/dashboards/show_component.rb +163 -0
  30. data/app/components/easy_admin/dashboards/table_card_component.rb +52 -0
  31. data/app/components/easy_admin/date_picker_component.rb +188 -0
  32. data/app/components/easy_admin/fields/base_component.rb +101 -0
  33. data/app/components/easy_admin/fields/belongs_to_edit_modal_component.rb +117 -0
  34. data/app/components/easy_admin/fields/form/belongs_to_component.rb +82 -0
  35. data/app/components/easy_admin/fields/form/boolean_component.rb +100 -0
  36. data/app/components/easy_admin/fields/form/date_component.rb +55 -0
  37. data/app/components/easy_admin/fields/form/datetime_component.rb +55 -0
  38. data/app/components/easy_admin/fields/form/email_component.rb +55 -0
  39. data/app/components/easy_admin/fields/form/file_component.rb +190 -0
  40. data/app/components/easy_admin/fields/form/has_many_component.rb +416 -0
  41. data/app/components/easy_admin/fields/form/json_component.rb +81 -0
  42. data/app/components/easy_admin/fields/form/number_component.rb +55 -0
  43. data/app/components/easy_admin/fields/form/select_component.rb +326 -0
  44. data/app/components/easy_admin/fields/form/text_component.rb +55 -0
  45. data/app/components/easy_admin/fields/form/textarea_component.rb +54 -0
  46. data/app/components/easy_admin/fields/index/belongs_to_component.rb +93 -0
  47. data/app/components/easy_admin/fields/index/boolean_component.rb +29 -0
  48. data/app/components/easy_admin/fields/index/date_component.rb +13 -0
  49. data/app/components/easy_admin/fields/index/datetime_component.rb +13 -0
  50. data/app/components/easy_admin/fields/index/email_component.rb +24 -0
  51. data/app/components/easy_admin/fields/index/filters/base_component.rb +48 -0
  52. data/app/components/easy_admin/fields/index/filters/boolean_component.rb +96 -0
  53. data/app/components/easy_admin/fields/index/filters/date_component.rb +182 -0
  54. data/app/components/easy_admin/fields/index/filters/number_component.rb +30 -0
  55. data/app/components/easy_admin/fields/index/filters/select_component.rb +101 -0
  56. data/app/components/easy_admin/fields/index/filters/string_component.rb +32 -0
  57. data/app/components/easy_admin/fields/index/json_component.rb +23 -0
  58. data/app/components/easy_admin/fields/index/number_component.rb +20 -0
  59. data/app/components/easy_admin/fields/index/select_component.rb +25 -0
  60. data/app/components/easy_admin/fields/index/text_component.rb +20 -0
  61. data/app/components/easy_admin/fields/inline_edit_modal_component.rb +135 -0
  62. data/app/components/easy_admin/fields/inline_edit_trigger_component.rb +144 -0
  63. data/app/components/easy_admin/fields/show/belongs_to_component.rb +93 -0
  64. data/app/components/easy_admin/fields/show/boolean_component.rb +21 -0
  65. data/app/components/easy_admin/fields/show/date_component.rb +13 -0
  66. data/app/components/easy_admin/fields/show/datetime_component.rb +13 -0
  67. data/app/components/easy_admin/fields/show/email_component.rb +19 -0
  68. data/app/components/easy_admin/fields/show/file_component.rb +304 -0
  69. data/app/components/easy_admin/fields/show/has_many_component.rb +192 -0
  70. data/app/components/easy_admin/fields/show/json_component.rb +45 -0
  71. data/app/components/easy_admin/fields/show/number_component.rb +20 -0
  72. data/app/components/easy_admin/fields/show/select_component.rb +25 -0
  73. data/app/components/easy_admin/fields/show/text_component.rb +17 -0
  74. data/app/components/easy_admin/fields/show/textarea_component.rb +26 -0
  75. data/app/components/easy_admin/filters_component.rb +120 -0
  76. data/app/components/easy_admin/form_tabs_component.rb +166 -0
  77. data/app/components/easy_admin/infinite_scroll_component.rb +82 -0
  78. data/app/components/easy_admin/lazy_chart_card_component.rb +128 -0
  79. data/app/components/easy_admin/lazy_metric_card_component.rb +76 -0
  80. data/app/components/easy_admin/modal_frame_component.rb +26 -0
  81. data/app/components/easy_admin/navbar_component.rb +226 -0
  82. data/app/components/easy_admin/notification_component.rb +83 -0
  83. data/app/components/easy_admin/pagination_component.rb +188 -0
  84. data/app/components/easy_admin/quick_filters_component.rb +65 -0
  85. data/app/components/easy_admin/resource_pagination_component.rb +14 -0
  86. data/app/components/easy_admin/resources/index_component.rb +211 -0
  87. data/app/components/easy_admin/resources/index_frame_component.rb +88 -0
  88. data/app/components/easy_admin/resources/show_page_actions_component.rb +324 -0
  89. data/app/components/easy_admin/resources/table_cell_component.rb +145 -0
  90. data/app/components/easy_admin/resources/table_component.rb +206 -0
  91. data/app/components/easy_admin/resources/table_row_component.rb +160 -0
  92. data/app/components/easy_admin/row_action_form_component.rb +127 -0
  93. data/app/components/easy_admin/scopes_component.rb +224 -0
  94. data/app/components/easy_admin/settings_sidebar_component.rb +140 -0
  95. data/app/components/easy_admin/show_layout_component.rb +600 -0
  96. data/app/components/easy_admin/sidebar_component.rb +174 -0
  97. data/app/components/easy_admin/turbo/response_component.rb +40 -0
  98. data/app/components/easy_admin/turbo/stream_component.rb +28 -0
  99. data/app/controllers/easy_admin/application_controller.rb +66 -0
  100. data/app/controllers/easy_admin/batch_actions_controller.rb +166 -0
  101. data/app/controllers/easy_admin/confirmation_modal_controller.rb +20 -0
  102. data/app/controllers/easy_admin/dashboard_controller.rb +6 -0
  103. data/app/controllers/easy_admin/dashboards_controller.rb +123 -0
  104. data/app/controllers/easy_admin/passwords_controller.rb +15 -0
  105. data/app/controllers/easy_admin/registrations_controller.rb +52 -0
  106. data/app/controllers/easy_admin/resources_controller.rb +907 -0
  107. data/app/controllers/easy_admin/row_actions_controller.rb +216 -0
  108. data/app/controllers/easy_admin/sessions_controller.rb +32 -0
  109. data/app/controllers/easy_admin/settings_controller.rb +94 -0
  110. data/app/helpers/easy_admin/application_helper.rb +4 -0
  111. data/app/helpers/easy_admin/dashboards_helper.rb +121 -0
  112. data/app/helpers/easy_admin/fields_helper.rb +27 -0
  113. data/app/helpers/easy_admin/pagy_helper.rb +30 -0
  114. data/app/helpers/easy_admin/resources_helper.rb +39 -0
  115. data/app/javascript/easy_admin/application.js +12 -0
  116. data/app/javascript/easy_admin/controllers/batch_modal_controller.js +66 -0
  117. data/app/javascript/easy_admin/controllers/batch_selection_controller.js +223 -0
  118. data/app/javascript/easy_admin/controllers/chart_controller.js +216 -0
  119. data/app/javascript/easy_admin/controllers/collapsible_filters_controller.js +118 -0
  120. data/app/javascript/easy_admin/controllers/confirmation_modal_controller.js +64 -0
  121. data/app/javascript/easy_admin/controllers/context_menu_controller.js +227 -0
  122. data/app/javascript/easy_admin/controllers/date_picker_controller.js +309 -0
  123. data/app/javascript/easy_admin/controllers/dropdown_controller.js +63 -0
  124. data/app/javascript/easy_admin/controllers/event_emitter_controller.js +19 -0
  125. data/app/javascript/easy_admin/controllers/file_controller.js +121 -0
  126. data/app/javascript/easy_admin/controllers/form_tabs_controller.js +100 -0
  127. data/app/javascript/easy_admin/controllers/has_many_search_controller.js +76 -0
  128. data/app/javascript/easy_admin/controllers/infinite_scroll_controller.js +174 -0
  129. data/app/javascript/easy_admin/controllers/ios_alert_controller.js +195 -0
  130. data/app/javascript/easy_admin/controllers/jsoneditor_controller.js +88 -0
  131. data/app/javascript/easy_admin/controllers/modal_controller.js +75 -0
  132. data/app/javascript/easy_admin/controllers/navbar_scroll_controller.js +76 -0
  133. data/app/javascript/easy_admin/controllers/notification_controller.js +48 -0
  134. data/app/javascript/easy_admin/controllers/row_action_controller.js +124 -0
  135. data/app/javascript/easy_admin/controllers/row_modal_controller.js +59 -0
  136. data/app/javascript/easy_admin/controllers/select_field_controller.js +618 -0
  137. data/app/javascript/easy_admin/controllers/settings_button_controller.js +8 -0
  138. data/app/javascript/easy_admin/controllers/settings_sidebar_controller.js +186 -0
  139. data/app/javascript/easy_admin/controllers/sidebar_controller.js +102 -0
  140. data/app/javascript/easy_admin/controllers/sidebar_mobile_controller.js +23 -0
  141. data/app/javascript/easy_admin/controllers/sidebar_nav_controller.js +96 -0
  142. data/app/javascript/easy_admin/controllers/table_controller.js +28 -0
  143. data/app/javascript/easy_admin/controllers/table_row_controller.js +16 -0
  144. data/app/javascript/easy_admin/controllers/toggle_switch_controller.js +22 -0
  145. data/app/javascript/easy_admin/controllers/turbo_stream_redirect.js +9 -0
  146. data/app/javascript/easy_admin/controllers.js +54 -0
  147. data/app/javascript/easy_admin.base.js +4 -0
  148. data/app/models/easy_admin/admin_user.rb +53 -0
  149. data/app/models/easy_admin/application_record.rb +5 -0
  150. data/app/views/easy_admin/dashboard/index.html.erb +3 -0
  151. data/app/views/easy_admin/dashboards/show.html.erb +7 -0
  152. data/app/views/easy_admin/passwords/edit.html.erb +42 -0
  153. data/app/views/easy_admin/passwords/new.html.erb +41 -0
  154. data/app/views/easy_admin/registrations/new.html.erb +65 -0
  155. data/app/views/easy_admin/resources/_redirect.turbo_stream.erb +3 -0
  156. data/app/views/easy_admin/resources/_table_rows.html.erb +46 -0
  157. data/app/views/easy_admin/resources/edit.html.erb +151 -0
  158. data/app/views/easy_admin/resources/index.html.erb +12 -0
  159. data/app/views/easy_admin/resources/index.turbo_stream.erb +139 -0
  160. data/app/views/easy_admin/resources/index_frame.html.erb +142 -0
  161. data/app/views/easy_admin/resources/new.html.erb +100 -0
  162. data/app/views/easy_admin/resources/show.html.erb +31 -0
  163. data/app/views/easy_admin/sessions/new.html.erb +55 -0
  164. data/app/views/easy_admin/settings/_form.html.erb +51 -0
  165. data/app/views/easy_admin/settings/index.html.erb +53 -0
  166. data/app/views/layouts/easy_admin/application.html.erb +48 -0
  167. data/app/views/layouts/easy_admin/auth.html.erb +34 -0
  168. data/config/initializers/easy_admin_card_factory.rb +27 -0
  169. data/config/initializers/pagy.rb +15 -0
  170. data/config/initializers/rack_mini_profiler.rb +67 -0
  171. data/config/routes.rb +70 -0
  172. data/db/migrate/20250101000001_create_easy_admin_admin_users.rb +45 -0
  173. data/lib/easy-admin.rb +32 -0
  174. data/lib/easy_admin/action.rb +159 -0
  175. data/lib/easy_admin/batch_action.rb +134 -0
  176. data/lib/easy_admin/configuration.rb +75 -0
  177. data/lib/easy_admin/dashboard.rb +110 -0
  178. data/lib/easy_admin/dashboard_registry.rb +30 -0
  179. data/lib/easy_admin/delete_action.rb +22 -0
  180. data/lib/easy_admin/engine.rb +54 -0
  181. data/lib/easy_admin/field.rb +118 -0
  182. data/lib/easy_admin/resource.rb +806 -0
  183. data/lib/easy_admin/resource_registry.rb +22 -0
  184. data/lib/easy_admin/types/json_type.rb +25 -0
  185. data/lib/easy_admin/version.rb +3 -0
  186. data/lib/generators/easy_admin/auth_generator.rb +69 -0
  187. data/lib/generators/easy_admin/card/card_generator.rb +94 -0
  188. data/lib/generators/easy_admin/card/templates/card_component.rb.erb +127 -0
  189. data/lib/generators/easy_admin/card/templates/card_component_spec.rb.erb +122 -0
  190. data/lib/generators/easy_admin/install/templates/easy_admin.rb +31 -0
  191. data/lib/generators/easy_admin/install_generator.rb +25 -0
  192. data/lib/generators/easy_admin/rbac/rbac_generator.rb +244 -0
  193. data/lib/generators/easy_admin/rbac/templates/add_rbac_to_admin_users.rb +23 -0
  194. data/lib/generators/easy_admin/rbac/templates/super_admin.rb +34 -0
  195. data/lib/generators/easy_admin/resource_generator.rb +43 -0
  196. data/lib/generators/easy_admin/templates/AUTH_README +35 -0
  197. data/lib/generators/easy_admin/templates/README +27 -0
  198. data/lib/generators/easy_admin/templates/create_easy_admin_admin_users.rb +45 -0
  199. data/lib/generators/easy_admin/templates/devise.rb +267 -0
  200. data/lib/generators/easy_admin/templates/easy_admin.rb +24 -0
  201. data/lib/generators/easy_admin/templates/resource.rb +29 -0
  202. data/lib/tasks/easy_admin_tasks.rake +4 -0
  203. metadata +445 -0
@@ -0,0 +1,618 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["search", "dropdown", "selectedItems", "option", "hiddenInput", "display", "noResults", "loading"]
5
+ static values = { multiple: Boolean, placeholder: String, suggest: Boolean, suggestUrl: String }
6
+
7
+ connect() {
8
+ this.setupDropdown()
9
+ this.setupClickOutside()
10
+ this.updateContainerState()
11
+ this.setupSuggestMode()
12
+
13
+ if (this.suggestValue) {
14
+ // For suggest mode, initialize with empty options
15
+ this.initializeSuggestMode()
16
+ } else {
17
+ // For static mode, use existing initialization
18
+ this.initializeOptionsVisibility()
19
+ this.initializeSelectedOption()
20
+ }
21
+ }
22
+
23
+ disconnect() {
24
+ if (this.clickOutsideHandler) {
25
+ document.removeEventListener('click', this.clickOutsideHandler)
26
+ }
27
+ }
28
+
29
+ setupDropdown() {
30
+ }
31
+
32
+ setupClickOutside() {
33
+ this.clickOutsideHandler = (event) => {
34
+ if (!this.element.contains(event.target)) {
35
+ this.closeDropdown()
36
+ }
37
+ }
38
+ document.addEventListener('click', this.clickOutsideHandler)
39
+ }
40
+
41
+ toggleDropdown() {
42
+ this.dropdownTarget.classList.toggle('show')
43
+ }
44
+
45
+ openDropdown() {
46
+ this.dropdownTarget.classList.add('show')
47
+ }
48
+
49
+ closeDropdown() {
50
+ this.dropdownTarget.classList.remove('show')
51
+ // Reset max-height for next opening
52
+ this.dropdownTarget.style.maxHeight = ''
53
+ }
54
+
55
+ animateToContentHeight() {
56
+ // Calculate the actual height needed for visible content
57
+ const dropdown = this.dropdownTarget
58
+
59
+ // Temporarily remove height restrictions to measure content
60
+ const originalMaxHeight = dropdown.style.maxHeight
61
+ dropdown.style.maxHeight = 'none'
62
+ const contentHeight = dropdown.scrollHeight
63
+
64
+ // Set current height as starting point
65
+ dropdown.style.maxHeight = originalMaxHeight || '300px'
66
+
67
+ // Force reflow
68
+ dropdown.offsetHeight
69
+
70
+ // Animate to actual content height, but cap at reasonable maximum
71
+ const targetHeight = Math.min(contentHeight, 300)
72
+ dropdown.style.maxHeight = `${targetHeight}px`
73
+ }
74
+
75
+ calculateTargetHeight(optionStates) {
76
+ const dropdown = this.dropdownTarget
77
+
78
+ // Temporarily hide all options
79
+ this.optionTargets.forEach(option => option.style.visibility = 'hidden')
80
+
81
+ // Show only the options that should be visible
82
+ optionStates.forEach(({option, shouldShow}) => {
83
+ option.style.display = shouldShow ? 'block' : 'none'
84
+ option.style.visibility = 'visible'
85
+ })
86
+
87
+ // Measure height
88
+ dropdown.style.maxHeight = 'none'
89
+ const height = dropdown.scrollHeight
90
+
91
+ // Restore all options visibility for now
92
+ this.optionTargets.forEach(option => {
93
+ option.style.display = 'block'
94
+ option.style.visibility = 'visible'
95
+ })
96
+
97
+ return Math.min(height, 300)
98
+ }
99
+
100
+ animateHeightChange(startHeight, targetHeight, callback) {
101
+ const dropdown = this.dropdownTarget
102
+
103
+ // Set explicit start height
104
+ dropdown.style.maxHeight = `${startHeight}px`
105
+
106
+ // Force reflow
107
+ dropdown.offsetHeight
108
+
109
+ // Trigger callback after a tiny delay to start the transition
110
+ requestAnimationFrame(() => {
111
+ dropdown.style.maxHeight = `${targetHeight}px`
112
+ if (callback) {
113
+ setTimeout(callback, 50) // Small delay so animation starts smoothly
114
+ }
115
+ })
116
+ }
117
+
118
+ filter(event) {
119
+ const searchTerm = event.target.value
120
+ console.log('Filter called with term:', searchTerm, 'Suggest mode:', this.suggestValue)
121
+
122
+ if (this.suggestValue) {
123
+ // For suggest mode, use debounced API search
124
+ this.debouncedSuggestSearch(searchTerm)
125
+ } else {
126
+ // For static mode, use existing filtering logic
127
+ this.filterStaticOptions(searchTerm)
128
+ }
129
+ }
130
+
131
+ filterStaticOptions(searchTerm) {
132
+ const searchTermLower = searchTerm.toLowerCase()
133
+ let visibleCount = 0
134
+
135
+ // First, set the dropdown to show and capture current height
136
+ this.openDropdown()
137
+ const startHeight = this.dropdownTarget.scrollHeight
138
+
139
+ // Determine which options should be visible
140
+ const optionStates = this.optionTargets.map(option => {
141
+ const text = option.textContent.toLowerCase()
142
+ // If search is empty, show all unselected options
143
+ const shouldShow = searchTermLower.length === 0
144
+ ? !this.isOptionSelected(option.dataset.value)
145
+ : text.includes(searchTermLower) && !this.isOptionSelected(option.dataset.value)
146
+ return { option, shouldShow }
147
+ })
148
+
149
+ // Count visible options first
150
+ visibleCount = optionStates.filter(state => state.shouldShow).length
151
+
152
+ // Show/hide no results message
153
+ if (this.hasNoResultsTarget) {
154
+ if (visibleCount === 0 && searchTermLower.length > 0) {
155
+ this.noResultsTarget.style.display = 'block'
156
+ } else {
157
+ this.noResultsTarget.style.display = 'none'
158
+ }
159
+ }
160
+
161
+ // Calculate target height by temporarily showing only visible options
162
+ const targetHeight = this.calculateTargetHeight(optionStates)
163
+
164
+ // Animate the height change first
165
+ this.animateHeightChange(startHeight, targetHeight, () => {
166
+ // After animation starts, update option visibility
167
+ optionStates.forEach(({option, shouldShow}) => {
168
+ option.style.display = shouldShow ? 'block' : 'none'
169
+ })
170
+ })
171
+ }
172
+
173
+ handleKeydown(event) {
174
+ if (event.key === 'Enter') {
175
+ event.preventDefault()
176
+ const visibleOptions = this.optionTargets.filter(option =>
177
+ option.style.display !== 'none'
178
+ )
179
+ if (visibleOptions.length > 0) {
180
+ this.selectOption({ target: visibleOptions[0] })
181
+ }
182
+ } else if (event.key === 'Escape') {
183
+ this.closeDropdown()
184
+ this.searchTarget.blur()
185
+ }
186
+ }
187
+
188
+ selectOption(event) {
189
+ const option = event.target
190
+ const value = option.dataset.value
191
+ const text = option.textContent.trim()
192
+
193
+ if (this.multipleValue) {
194
+ this.addMultipleSelection(value, text)
195
+ this.clearSearch()
196
+ } else {
197
+ this.setSingleSelection(value, text)
198
+ }
199
+
200
+ this.closeDropdown()
201
+ this.dispatchChangeEvent()
202
+ }
203
+
204
+ addMultipleSelection(value, text) {
205
+ // Check if already selected
206
+ const existingItems = Array.from(this.selectedItemsTarget.children)
207
+ const alreadySelected = existingItems.some(item =>
208
+ item.dataset.value === value
209
+ )
210
+
211
+ if (!alreadySelected) {
212
+ // Add to selected items display
213
+ const selectedItem = this.createSelectedItem(value, text)
214
+ this.selectedItemsTarget.appendChild(selectedItem)
215
+
216
+ // Add hidden input
217
+ this.addHiddenInput(value)
218
+
219
+ // Hide the option
220
+ const option = this.optionTargets.find(opt => opt.dataset.value === value)
221
+ if (option) {
222
+ option.style.display = 'none'
223
+ }
224
+
225
+ // Update container state
226
+ this.updateContainerState()
227
+ }
228
+ }
229
+
230
+ setSingleSelection(value, text) {
231
+ // Update the input (either search target for suggest mode or display target for static mode)
232
+ if (this.suggestValue) {
233
+ // For suggest mode, update the search input
234
+ if (this.hasSearchTarget) {
235
+ this.searchTarget.value = text
236
+ }
237
+ } else {
238
+ // For static mode, update the display input
239
+ if (this.hasDisplayTarget) {
240
+ this.displayTarget.value = text
241
+ }
242
+ }
243
+
244
+ // Update the hidden input
245
+ const hiddenInput = this.hiddenInputTargets[0]
246
+ if (hiddenInput) {
247
+ hiddenInput.value = value
248
+ }
249
+
250
+ // Update visual selection in dropdown
251
+ this.updateOptionSelection(value)
252
+ }
253
+
254
+ createSelectedItem(value, text) {
255
+ const span = document.createElement('span')
256
+ span.className = 'selected-item'
257
+ span.dataset.value = value
258
+ span.innerHTML = `${text}<span class="remove-item">×</span>`
259
+
260
+ span.addEventListener('click', this.removeItem.bind(this))
261
+
262
+ return span
263
+ }
264
+
265
+ addHiddenInput(value) {
266
+ const input = document.createElement('input')
267
+ input.type = 'hidden'
268
+ input.name = this.getFieldName()
269
+ input.value = value
270
+ input.dataset.selectFieldTarget = 'hiddenInput'
271
+
272
+ this.element.appendChild(input)
273
+ }
274
+
275
+ removeItem(event) {
276
+ const selectedItem = event.currentTarget
277
+ const value = selectedItem.dataset.value
278
+
279
+ // Remove the selected item display
280
+ selectedItem.remove()
281
+
282
+ // Remove corresponding hidden input
283
+ const hiddenInput = this.hiddenInputTargets.find(input =>
284
+ input.value === value
285
+ )
286
+
287
+ if (hiddenInput) {
288
+ hiddenInput.remove()
289
+ }
290
+
291
+ // Show the option again
292
+ const option = this.optionTargets.find(opt => opt.dataset.value === value)
293
+ if (option) {
294
+ option.style.display = 'block'
295
+ }
296
+
297
+ // Update container state
298
+ this.updateContainerState()
299
+
300
+ this.dispatchChangeEvent()
301
+ }
302
+
303
+ clearAll() {
304
+ // Remove all selected items
305
+ this.selectedItemsTarget.innerHTML = ''
306
+
307
+ // Remove all hidden inputs
308
+ this.hiddenInputTargets.forEach(input => input.remove())
309
+
310
+ // Show all options
311
+ this.optionTargets.forEach(option => {
312
+ option.style.display = 'block'
313
+ })
314
+
315
+ // Update container state
316
+ this.updateContainerState()
317
+
318
+ // Clear search
319
+ this.clearSearch()
320
+
321
+ this.dispatchChangeEvent()
322
+ }
323
+
324
+ clearSearch() {
325
+ if (this.hasSearchTarget) {
326
+ this.searchTarget.value = ''
327
+ // Reset all options to visible
328
+ this.optionTargets.forEach(option => {
329
+ if (!this.isOptionSelected(option.dataset.value)) {
330
+ option.style.display = 'block'
331
+ }
332
+ })
333
+ // Hide no results message
334
+ if (this.hasNoResultsTarget) {
335
+ this.noResultsTarget.style.display = 'none'
336
+ }
337
+ // Animate to new height
338
+ this.animateToContentHeight()
339
+ }
340
+ }
341
+
342
+ isOptionSelected(value) {
343
+ if (this.multipleValue) {
344
+ const selectedItems = Array.from(this.selectedItemsTarget.children)
345
+ return selectedItems.some(item => item.dataset.value === value)
346
+ } else {
347
+ // For single select, check the hidden input value
348
+ const hiddenInput = this.hiddenInputTargets[0]
349
+ return hiddenInput && hiddenInput.value === value
350
+ }
351
+ }
352
+
353
+ getFieldName() {
354
+ // Extract field name from the first hidden input if exists
355
+ const firstHidden = this.hiddenInputTargets[0]
356
+ if (firstHidden) {
357
+ return firstHidden.name
358
+ }
359
+
360
+ // Use field name from data attribute
361
+ const fieldName = this.element.dataset.fieldName
362
+ if (fieldName) {
363
+ const form = this.element.closest('form')
364
+ const modelName = form.querySelector('[name*="["]')?.name.match(/^(\w+)\[/)?.[1] || 'user'
365
+ return this.multipleValue ? `${modelName}[${fieldName}][]` : `${modelName}[${fieldName}]`
366
+ }
367
+
368
+ // Final fallback
369
+ return this.multipleValue ? 'user[field][]' : 'user[field]'
370
+ }
371
+
372
+ initializeOptionsVisibility() {
373
+ // Show all unselected options initially
374
+ this.optionTargets.forEach(option => {
375
+ if (!this.isOptionSelected(option.dataset.value)) {
376
+ option.style.display = 'block'
377
+ } else {
378
+ option.style.display = 'none'
379
+ }
380
+ })
381
+ }
382
+
383
+ initializeSelectedOption() {
384
+ // For single select, highlight the currently selected option
385
+ if (!this.multipleValue) {
386
+ const hiddenInput = this.hiddenInputTargets[0]
387
+ if (hiddenInput && hiddenInput.value) {
388
+ this.updateOptionSelection(hiddenInput.value)
389
+ }
390
+ }
391
+ }
392
+
393
+ updateOptionSelection(selectedValue) {
394
+ // Remove selected class from all options
395
+ this.optionTargets.forEach(option => {
396
+ option.classList.remove('selected', 'bg-blue-100', 'text-blue-900')
397
+ })
398
+
399
+ // Add selected class to the current option
400
+ const selectedOption = this.optionTargets.find(option =>
401
+ option.dataset.value === selectedValue
402
+ )
403
+ if (selectedOption) {
404
+ selectedOption.classList.add('selected', 'bg-blue-100', 'text-blue-900')
405
+ }
406
+ }
407
+
408
+ updateContainerState() {
409
+ if (this.multipleValue && this.hasSelectedItemsTarget) {
410
+ const hasSelections = this.selectedItemsTarget.children.length > 0
411
+
412
+ if (hasSelections) {
413
+ this.element.classList.add('has-selections')
414
+ } else {
415
+ this.element.classList.remove('has-selections')
416
+ }
417
+ }
418
+ }
419
+
420
+ removeOption(event) {
421
+ const element = event.target
422
+ const valueToRemove = element.dataset.value || element.dataset.removeValue
423
+
424
+ if (!valueToRemove) return
425
+
426
+ // Remove from selected items if multiple
427
+ if (this.multipleValue) {
428
+ const selectedItem = this.selectedItemsTarget.querySelector(`[data-value="${valueToRemove}"]`)
429
+ if (selectedItem) {
430
+ selectedItem.remove()
431
+ }
432
+ }
433
+
434
+ // Remove corresponding hidden input
435
+ const hiddenInput = this.hiddenInputTargets.find(input =>
436
+ input.value === valueToRemove
437
+ )
438
+ if (hiddenInput) {
439
+ hiddenInput.remove()
440
+ }
441
+
442
+ // Show the option again in dropdown
443
+ const option = this.optionTargets.find(opt => opt.dataset.value === valueToRemove)
444
+ if (option) {
445
+ option.style.display = 'block'
446
+ }
447
+
448
+ // Clear single select display if needed
449
+ if (!this.multipleValue && this.hasDisplayTarget) {
450
+ this.displayTarget.value = ''
451
+ }
452
+
453
+ // Update container state
454
+ this.updateContainerState()
455
+
456
+ // Animate removal of the element that triggered this action
457
+ this.animateRemoval(element)
458
+
459
+ this.dispatchChangeEvent()
460
+ }
461
+
462
+ animateRemoval(element) {
463
+ const itemToRemove = element.closest('.ea-has-many-item') || element.closest('.selected-item')
464
+ if (!itemToRemove) return
465
+
466
+ // Add removing class for CSS transition
467
+ itemToRemove.classList.add('removing')
468
+
469
+ // Remove after animation completes
470
+ setTimeout(() => {
471
+ if (itemToRemove.parentNode) {
472
+ itemToRemove.remove()
473
+ }
474
+ }, 300) // Match CSS transition duration
475
+ }
476
+
477
+ dispatchChangeEvent() {
478
+ const event = new CustomEvent('select-field:change', {
479
+ detail: {
480
+ selectedValues: this.hiddenInputTargets.map(input => input.value),
481
+ element: this.element
482
+ },
483
+ bubbles: true
484
+ })
485
+ this.element.dispatchEvent(event)
486
+ }
487
+
488
+ // Suggest mode methods
489
+ setupSuggestMode() {
490
+ if (this.suggestValue) {
491
+ // Setup debounced search
492
+ this.debounceTimeout = null
493
+ this.lastSearchTerm = ''
494
+ }
495
+ }
496
+
497
+ initializeSuggestMode() {
498
+ // Clear any existing options from static mode
499
+ this.optionTargets.forEach(option => option.remove())
500
+
501
+ // Show initial empty state
502
+ this.showMessage('Type to search...')
503
+ }
504
+
505
+ debouncedSuggestSearch(searchTerm) {
506
+ // Clear existing timeout
507
+ if (this.debounceTimeout) {
508
+ clearTimeout(this.debounceTimeout)
509
+ }
510
+
511
+ // Don't search if term hasn't changed
512
+ if (searchTerm === this.lastSearchTerm) {
513
+ return
514
+ }
515
+
516
+ this.lastSearchTerm = searchTerm
517
+
518
+ // Show loading state immediately
519
+ this.showLoadingState()
520
+ this.openDropdown()
521
+
522
+ // Debounce the actual search
523
+ this.debounceTimeout = setTimeout(() => {
524
+ this.performSuggestSearch(searchTerm)
525
+ }, 300) // 300ms debounce
526
+ }
527
+
528
+ async performSuggestSearch(searchTerm) {
529
+ try {
530
+ const url = new URL(this.suggestUrlValue, window.location.origin)
531
+ url.searchParams.set('q', searchTerm)
532
+
533
+ const response = await fetch(url.toString(), {
534
+ headers: {
535
+ 'Accept': 'application/json',
536
+ 'X-Requested-With': 'XMLHttpRequest'
537
+ }
538
+ })
539
+
540
+ if (!response.ok) {
541
+ throw new Error('Network response was not ok')
542
+ }
543
+
544
+ const data = await response.json()
545
+ this.renderSuggestOptions(data.options || [])
546
+
547
+ } catch (error) {
548
+ console.error('Suggest search failed:', error)
549
+ this.showMessage('Search failed. Please try again.')
550
+ }
551
+ }
552
+
553
+ renderSuggestOptions(options) {
554
+ // Clear existing options
555
+ this.clearDynamicOptions()
556
+
557
+ if (options.length === 0) {
558
+ this.showMessage('No options found')
559
+ return
560
+ }
561
+
562
+ // Hide loading and no-results messages
563
+ this.hideAllMessages()
564
+
565
+ // Create and insert new options
566
+ const dropdown = this.dropdownTarget
567
+ options.forEach(option => {
568
+ const optionElement = this.createOptionElement(option)
569
+ dropdown.appendChild(optionElement)
570
+ })
571
+
572
+ // Animate to new height
573
+ this.animateToContentHeight()
574
+ }
575
+
576
+ createOptionElement(option) {
577
+ const [text, value] = Array.isArray(option) ? option : [option.text || option, option.value || option]
578
+
579
+ const div = document.createElement('div')
580
+ div.className = 'select-option px-3 py-2 text-sm text-gray-900 cursor-pointer hover:bg-blue-50 hover:text-blue-900 transition-colors duration-150'
581
+ div.dataset.value = value
582
+ div.dataset.action = 'click->select-field#selectOption'
583
+ div.dataset.selectFieldTarget = 'option'
584
+ div.textContent = text
585
+
586
+ return div
587
+ }
588
+
589
+ clearDynamicOptions() {
590
+ // Remove all dynamically created options
591
+ const options = this.dropdownTarget.querySelectorAll('[data-select-field-target="option"]')
592
+ options.forEach(option => option.remove())
593
+ }
594
+
595
+ showLoadingState() {
596
+ this.hideAllMessages()
597
+ if (this.hasLoadingTarget) {
598
+ this.loadingTarget.style.display = 'block'
599
+ }
600
+ }
601
+
602
+ showMessage(message) {
603
+ this.hideAllMessages()
604
+ if (this.hasNoResultsTarget) {
605
+ this.noResultsTarget.textContent = message
606
+ this.noResultsTarget.style.display = 'block'
607
+ }
608
+ }
609
+
610
+ hideAllMessages() {
611
+ if (this.hasNoResultsTarget) {
612
+ this.noResultsTarget.style.display = 'none'
613
+ }
614
+ if (this.hasLoadingTarget) {
615
+ this.loadingTarget.style.display = 'none'
616
+ }
617
+ }
618
+ }
@@ -0,0 +1,8 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ open() {
5
+ console.log('Settings button clicked - dispatching settings:open event')
6
+ document.dispatchEvent(new CustomEvent('settings:open'))
7
+ }
8
+ }