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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +8 -0
- data/app/assets/builds/easy_admin.base.js +43505 -0
- data/app/assets/builds/easy_admin.base.js.map +7 -0
- data/app/assets/builds/easy_admin.css +6141 -0
- data/app/assets/config/easy_admin_manifest.js +1 -0
- data/app/assets/images/jsoneditor-icons.svg +749 -0
- data/app/assets/stylesheets/easy_admin/application.tailwind.css +390 -0
- data/app/components/easy_admin/base_component.rb +35 -0
- data/app/components/easy_admin/batch_action_bar_component.rb +125 -0
- data/app/components/easy_admin/batch_action_form_component.rb +124 -0
- data/app/components/easy_admin/combined_filters_component.rb +232 -0
- data/app/components/easy_admin/confirmation_modal_component.rb +61 -0
- data/app/components/easy_admin/context_menu_component.rb +161 -0
- data/app/components/easy_admin/dashboards/base_card_component.rb +152 -0
- data/app/components/easy_admin/dashboards/card_error_component.rb +23 -0
- data/app/components/easy_admin/dashboards/card_factory.rb +90 -0
- data/app/components/easy_admin/dashboards/card_stream_component.rb +22 -0
- data/app/components/easy_admin/dashboards/cards/base_card_component.rb +54 -0
- data/app/components/easy_admin/dashboards/cards/chart_card_component.rb +175 -0
- data/app/components/easy_admin/dashboards/cards/custom_card_component.rb +50 -0
- data/app/components/easy_admin/dashboards/cards/metric_card_component.rb +164 -0
- data/app/components/easy_admin/dashboards/cards/table_card_component.rb +148 -0
- data/app/components/easy_admin/dashboards/chart_card_component.rb +44 -0
- data/app/components/easy_admin/dashboards/metric_card_component.rb +56 -0
- data/app/components/easy_admin/dashboards/refresh_stream_component.rb +279 -0
- data/app/components/easy_admin/dashboards/show_component.rb +163 -0
- data/app/components/easy_admin/dashboards/table_card_component.rb +52 -0
- data/app/components/easy_admin/date_picker_component.rb +188 -0
- data/app/components/easy_admin/fields/base_component.rb +101 -0
- data/app/components/easy_admin/fields/belongs_to_edit_modal_component.rb +117 -0
- data/app/components/easy_admin/fields/form/belongs_to_component.rb +82 -0
- data/app/components/easy_admin/fields/form/boolean_component.rb +100 -0
- data/app/components/easy_admin/fields/form/date_component.rb +55 -0
- data/app/components/easy_admin/fields/form/datetime_component.rb +55 -0
- data/app/components/easy_admin/fields/form/email_component.rb +55 -0
- data/app/components/easy_admin/fields/form/file_component.rb +190 -0
- data/app/components/easy_admin/fields/form/has_many_component.rb +416 -0
- data/app/components/easy_admin/fields/form/json_component.rb +81 -0
- data/app/components/easy_admin/fields/form/number_component.rb +55 -0
- data/app/components/easy_admin/fields/form/select_component.rb +326 -0
- data/app/components/easy_admin/fields/form/text_component.rb +55 -0
- data/app/components/easy_admin/fields/form/textarea_component.rb +54 -0
- data/app/components/easy_admin/fields/index/belongs_to_component.rb +93 -0
- data/app/components/easy_admin/fields/index/boolean_component.rb +29 -0
- data/app/components/easy_admin/fields/index/date_component.rb +13 -0
- data/app/components/easy_admin/fields/index/datetime_component.rb +13 -0
- data/app/components/easy_admin/fields/index/email_component.rb +24 -0
- data/app/components/easy_admin/fields/index/filters/base_component.rb +48 -0
- data/app/components/easy_admin/fields/index/filters/boolean_component.rb +96 -0
- data/app/components/easy_admin/fields/index/filters/date_component.rb +182 -0
- data/app/components/easy_admin/fields/index/filters/number_component.rb +30 -0
- data/app/components/easy_admin/fields/index/filters/select_component.rb +101 -0
- data/app/components/easy_admin/fields/index/filters/string_component.rb +32 -0
- data/app/components/easy_admin/fields/index/json_component.rb +23 -0
- data/app/components/easy_admin/fields/index/number_component.rb +20 -0
- data/app/components/easy_admin/fields/index/select_component.rb +25 -0
- data/app/components/easy_admin/fields/index/text_component.rb +20 -0
- data/app/components/easy_admin/fields/inline_edit_modal_component.rb +135 -0
- data/app/components/easy_admin/fields/inline_edit_trigger_component.rb +144 -0
- data/app/components/easy_admin/fields/show/belongs_to_component.rb +93 -0
- data/app/components/easy_admin/fields/show/boolean_component.rb +21 -0
- data/app/components/easy_admin/fields/show/date_component.rb +13 -0
- data/app/components/easy_admin/fields/show/datetime_component.rb +13 -0
- data/app/components/easy_admin/fields/show/email_component.rb +19 -0
- data/app/components/easy_admin/fields/show/file_component.rb +304 -0
- data/app/components/easy_admin/fields/show/has_many_component.rb +192 -0
- data/app/components/easy_admin/fields/show/json_component.rb +45 -0
- data/app/components/easy_admin/fields/show/number_component.rb +20 -0
- data/app/components/easy_admin/fields/show/select_component.rb +25 -0
- data/app/components/easy_admin/fields/show/text_component.rb +17 -0
- data/app/components/easy_admin/fields/show/textarea_component.rb +26 -0
- data/app/components/easy_admin/filters_component.rb +120 -0
- data/app/components/easy_admin/form_tabs_component.rb +166 -0
- data/app/components/easy_admin/infinite_scroll_component.rb +82 -0
- data/app/components/easy_admin/lazy_chart_card_component.rb +128 -0
- data/app/components/easy_admin/lazy_metric_card_component.rb +76 -0
- data/app/components/easy_admin/modal_frame_component.rb +26 -0
- data/app/components/easy_admin/navbar_component.rb +226 -0
- data/app/components/easy_admin/notification_component.rb +83 -0
- data/app/components/easy_admin/pagination_component.rb +188 -0
- data/app/components/easy_admin/quick_filters_component.rb +65 -0
- data/app/components/easy_admin/resource_pagination_component.rb +14 -0
- data/app/components/easy_admin/resources/index_component.rb +211 -0
- data/app/components/easy_admin/resources/index_frame_component.rb +88 -0
- data/app/components/easy_admin/resources/show_page_actions_component.rb +324 -0
- data/app/components/easy_admin/resources/table_cell_component.rb +145 -0
- data/app/components/easy_admin/resources/table_component.rb +206 -0
- data/app/components/easy_admin/resources/table_row_component.rb +160 -0
- data/app/components/easy_admin/row_action_form_component.rb +127 -0
- data/app/components/easy_admin/scopes_component.rb +224 -0
- data/app/components/easy_admin/settings_sidebar_component.rb +140 -0
- data/app/components/easy_admin/show_layout_component.rb +600 -0
- data/app/components/easy_admin/sidebar_component.rb +174 -0
- data/app/components/easy_admin/turbo/response_component.rb +40 -0
- data/app/components/easy_admin/turbo/stream_component.rb +28 -0
- data/app/controllers/easy_admin/application_controller.rb +66 -0
- data/app/controllers/easy_admin/batch_actions_controller.rb +166 -0
- data/app/controllers/easy_admin/confirmation_modal_controller.rb +20 -0
- data/app/controllers/easy_admin/dashboard_controller.rb +6 -0
- data/app/controllers/easy_admin/dashboards_controller.rb +123 -0
- data/app/controllers/easy_admin/passwords_controller.rb +15 -0
- data/app/controllers/easy_admin/registrations_controller.rb +52 -0
- data/app/controllers/easy_admin/resources_controller.rb +907 -0
- data/app/controllers/easy_admin/row_actions_controller.rb +216 -0
- data/app/controllers/easy_admin/sessions_controller.rb +32 -0
- data/app/controllers/easy_admin/settings_controller.rb +94 -0
- data/app/helpers/easy_admin/application_helper.rb +4 -0
- data/app/helpers/easy_admin/dashboards_helper.rb +121 -0
- data/app/helpers/easy_admin/fields_helper.rb +27 -0
- data/app/helpers/easy_admin/pagy_helper.rb +30 -0
- data/app/helpers/easy_admin/resources_helper.rb +39 -0
- data/app/javascript/easy_admin/application.js +12 -0
- data/app/javascript/easy_admin/controllers/batch_modal_controller.js +66 -0
- data/app/javascript/easy_admin/controllers/batch_selection_controller.js +223 -0
- data/app/javascript/easy_admin/controllers/chart_controller.js +216 -0
- data/app/javascript/easy_admin/controllers/collapsible_filters_controller.js +118 -0
- data/app/javascript/easy_admin/controllers/confirmation_modal_controller.js +64 -0
- data/app/javascript/easy_admin/controllers/context_menu_controller.js +227 -0
- data/app/javascript/easy_admin/controllers/date_picker_controller.js +309 -0
- data/app/javascript/easy_admin/controllers/dropdown_controller.js +63 -0
- data/app/javascript/easy_admin/controllers/event_emitter_controller.js +19 -0
- data/app/javascript/easy_admin/controllers/file_controller.js +121 -0
- data/app/javascript/easy_admin/controllers/form_tabs_controller.js +100 -0
- data/app/javascript/easy_admin/controllers/has_many_search_controller.js +76 -0
- data/app/javascript/easy_admin/controllers/infinite_scroll_controller.js +174 -0
- data/app/javascript/easy_admin/controllers/ios_alert_controller.js +195 -0
- data/app/javascript/easy_admin/controllers/jsoneditor_controller.js +88 -0
- data/app/javascript/easy_admin/controllers/modal_controller.js +75 -0
- data/app/javascript/easy_admin/controllers/navbar_scroll_controller.js +76 -0
- data/app/javascript/easy_admin/controllers/notification_controller.js +48 -0
- data/app/javascript/easy_admin/controllers/row_action_controller.js +124 -0
- data/app/javascript/easy_admin/controllers/row_modal_controller.js +59 -0
- data/app/javascript/easy_admin/controllers/select_field_controller.js +618 -0
- data/app/javascript/easy_admin/controllers/settings_button_controller.js +8 -0
- data/app/javascript/easy_admin/controllers/settings_sidebar_controller.js +186 -0
- data/app/javascript/easy_admin/controllers/sidebar_controller.js +102 -0
- data/app/javascript/easy_admin/controllers/sidebar_mobile_controller.js +23 -0
- data/app/javascript/easy_admin/controllers/sidebar_nav_controller.js +96 -0
- data/app/javascript/easy_admin/controllers/table_controller.js +28 -0
- data/app/javascript/easy_admin/controllers/table_row_controller.js +16 -0
- data/app/javascript/easy_admin/controllers/toggle_switch_controller.js +22 -0
- data/app/javascript/easy_admin/controllers/turbo_stream_redirect.js +9 -0
- data/app/javascript/easy_admin/controllers.js +54 -0
- data/app/javascript/easy_admin.base.js +4 -0
- data/app/models/easy_admin/admin_user.rb +53 -0
- data/app/models/easy_admin/application_record.rb +5 -0
- data/app/views/easy_admin/dashboard/index.html.erb +3 -0
- data/app/views/easy_admin/dashboards/show.html.erb +7 -0
- data/app/views/easy_admin/passwords/edit.html.erb +42 -0
- data/app/views/easy_admin/passwords/new.html.erb +41 -0
- data/app/views/easy_admin/registrations/new.html.erb +65 -0
- data/app/views/easy_admin/resources/_redirect.turbo_stream.erb +3 -0
- data/app/views/easy_admin/resources/_table_rows.html.erb +46 -0
- data/app/views/easy_admin/resources/edit.html.erb +151 -0
- data/app/views/easy_admin/resources/index.html.erb +12 -0
- data/app/views/easy_admin/resources/index.turbo_stream.erb +139 -0
- data/app/views/easy_admin/resources/index_frame.html.erb +142 -0
- data/app/views/easy_admin/resources/new.html.erb +100 -0
- data/app/views/easy_admin/resources/show.html.erb +31 -0
- data/app/views/easy_admin/sessions/new.html.erb +55 -0
- data/app/views/easy_admin/settings/_form.html.erb +51 -0
- data/app/views/easy_admin/settings/index.html.erb +53 -0
- data/app/views/layouts/easy_admin/application.html.erb +48 -0
- data/app/views/layouts/easy_admin/auth.html.erb +34 -0
- data/config/initializers/easy_admin_card_factory.rb +27 -0
- data/config/initializers/pagy.rb +15 -0
- data/config/initializers/rack_mini_profiler.rb +67 -0
- data/config/routes.rb +70 -0
- data/db/migrate/20250101000001_create_easy_admin_admin_users.rb +45 -0
- data/lib/easy-admin.rb +32 -0
- data/lib/easy_admin/action.rb +159 -0
- data/lib/easy_admin/batch_action.rb +134 -0
- data/lib/easy_admin/configuration.rb +75 -0
- data/lib/easy_admin/dashboard.rb +110 -0
- data/lib/easy_admin/dashboard_registry.rb +30 -0
- data/lib/easy_admin/delete_action.rb +22 -0
- data/lib/easy_admin/engine.rb +54 -0
- data/lib/easy_admin/field.rb +118 -0
- data/lib/easy_admin/resource.rb +806 -0
- data/lib/easy_admin/resource_registry.rb +22 -0
- data/lib/easy_admin/types/json_type.rb +25 -0
- data/lib/easy_admin/version.rb +3 -0
- data/lib/generators/easy_admin/auth_generator.rb +69 -0
- data/lib/generators/easy_admin/card/card_generator.rb +94 -0
- data/lib/generators/easy_admin/card/templates/card_component.rb.erb +127 -0
- data/lib/generators/easy_admin/card/templates/card_component_spec.rb.erb +122 -0
- data/lib/generators/easy_admin/install/templates/easy_admin.rb +31 -0
- data/lib/generators/easy_admin/install_generator.rb +25 -0
- data/lib/generators/easy_admin/rbac/rbac_generator.rb +244 -0
- data/lib/generators/easy_admin/rbac/templates/add_rbac_to_admin_users.rb +23 -0
- data/lib/generators/easy_admin/rbac/templates/super_admin.rb +34 -0
- data/lib/generators/easy_admin/resource_generator.rb +43 -0
- data/lib/generators/easy_admin/templates/AUTH_README +35 -0
- data/lib/generators/easy_admin/templates/README +27 -0
- data/lib/generators/easy_admin/templates/create_easy_admin_admin_users.rb +45 -0
- data/lib/generators/easy_admin/templates/devise.rb +267 -0
- data/lib/generators/easy_admin/templates/easy_admin.rb +24 -0
- data/lib/generators/easy_admin/templates/resource.rb +29 -0
- data/lib/tasks/easy_admin_tasks.rake +4 -0
- 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
|
+
}
|