Skip to content

Commit 08d2f18

Browse files
authored
Enhance SBAdmin widgets with full-width and searchable options (#131)
* Enhance SBAdmin widgets with full-width and searchable options - Added `searchable_template_name` and `searchable` parameter to `SBAdminSelectWidget` and `SBAdminMultipleChoiceWidget`. - Introduced `full_width` parameter to adjust widget width dynamically. - Implemented `syncDropdownMenuWidth` utility to synchronize dropdown menu width with the wrapper. - Created `StaticAutocomplete` class for static option handling in autocomplete widgets. - Updated templates to support new full-width and searchable features. This update improves the usability and flexibility of the SBAdmin widget components. * Refactor SBAdminSelectWidget and introduce searchable choice widgets - Removed the `searchable` parameter from `SBAdminSelectWidget` and `SBAdminMultipleChoiceWidget`. - Added `SBAdminChoiceSearchableWidget` and `SBAdminMultipleChoiceSearchableWidget` for searchable dropdowns. - Updated widget initialization to streamline parameters and improve clarity. - Enhanced context handling for full-width display in new searchable widgets. This update improves the organization and usability of selection widgets in the SBAdmin interface. * Refactor autocomplete and choice search widget templates - Removed the `full_width` condition from the wrapper div in both `autocomplete.html` and `choice_search.html` templates. - Simplified the structure of the widget templates to enhance readability and maintainability. This update streamlines the widget templates for better consistency in the SBAdmin interface. * Update SBAdmin widget initialization to set full_width to False - Changed the default value of the `full_width` parameter to False in `SBAdminChoiceSearchableWidget`, `SBAdminMultipleChoiceSearchableWidget`, and `SBAdminAutocompleteWidget`. - This adjustment aligns the widget behavior with recent template refactoring for improved consistency in the SBAdmin interface. --------- Co-authored-by: oko-vac <oko-vac@users.noreply.github.com>
1 parent f2afb14 commit 08d2f18

9 files changed

Lines changed: 209 additions & 6 deletions

File tree

.coverage

68 KB
Binary file not shown.

src/django_smartbase_admin/admin/widgets.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,50 @@ class SBAdminMultipleChoiceInlineWidget(SBAdminMultipleChoiceWidget):
278278
option_template_name = "sb_admin/widgets/checkbox.html"
279279

280280

281+
class SBAdminChoiceSearchableWidget(SBAdminBaseWidget, forms.Select):
282+
"""Single-choice dropdown with client-side search (Choices.js).
283+
284+
Shares the autocomplete UI shell with ``SBAdminAutocompleteWidget`` but the
285+
options are rendered inline as ``<option>`` tags — no API fetch, no
286+
pagination. The native ``<select>`` submits as a single value, so a plain
287+
``ChoiceField`` is enough on the backend.
288+
"""
289+
290+
template_name = "sb_admin/widgets/choice_search.html"
291+
292+
def __init__(self, form_field=None, attrs=None, choices=(), full_width=False):
293+
self.full_width = full_width
294+
super().__init__(
295+
form_field, attrs={"class": "input", **(attrs or {})}, choices=choices
296+
)
297+
298+
def get_context(self, name, value, attrs):
299+
context = super().get_context(name, value, attrs)
300+
context["widget"]["full_width"] = self.full_width
301+
return context
302+
303+
304+
class SBAdminMultipleChoiceSearchableWidget(SBAdminBaseWidget, forms.SelectMultiple):
305+
"""Multi-choice dropdown with client-side search (Choices.js).
306+
307+
Same UI shell as ``SBAdminChoiceSearchableWidget`` but renders a
308+
``<select multiple>`` and pairs with ``MultipleChoiceField``.
309+
"""
310+
311+
template_name = "sb_admin/widgets/choice_search.html"
312+
313+
def __init__(self, form_field=None, attrs=None, choices=(), full_width=False):
314+
self.full_width = full_width
315+
super().__init__(
316+
form_field, attrs={"class": "input", **(attrs or {})}, choices=choices
317+
)
318+
319+
def get_context(self, name, value, attrs):
320+
context = super().get_context(name, value, attrs)
321+
context["widget"]["full_width"] = self.full_width
322+
return context
323+
324+
281325
class SBAdminNullBooleanSelectWidget(SBAdminBaseWidget, forms.NullBooleanSelect):
282326
template_name = "sb_admin/widgets/select.html"
283327
option_template_name = "sb_admin/widgets/select_option.html"
@@ -451,6 +495,7 @@ class SBAdminAutocompleteWidget(
451495
default_create_data = None
452496
forward_to_create = None
453497
reload_on_save = None
498+
full_width = False
454499
REQUEST_CREATED_DATA_KEY = "autocomplete_created_data"
455500

456501
def __init__(self, form_field=None, *args, **kwargs):
@@ -459,6 +504,7 @@ def __init__(self, form_field=None, *args, **kwargs):
459504
self.allow_add = kwargs.pop("allow_add", None)
460505
self.create_value_field = kwargs.pop("create_value_field", None)
461506
self.forward_to_create = kwargs.pop("forward_to_create", [])
507+
self.full_width = kwargs.pop("full_width", self.full_width)
462508
super().__init__(form_field, *args, **kwargs)
463509
self.attrs = {} if attrs is None else attrs.copy()
464510
if self.multiselect and self.allow_add:

src/django_smartbase_admin/static/sb_admin/src/css/_choices.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,3 @@
150150
@apply text-dark m-0 p-0;
151151
}
152152
}
153-

src/django_smartbase_admin/static/sb_admin/src/js/autocomplete.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Choices from "choices.js"
2-
import {createIcon, filterInputValueChangedUtil} from "./utils"
2+
import {createIcon, filterInputValueChangedUtil, syncDropdownMenuWidth} from "./utils"
33
import {choicesJSListeners, choicesJSOptions} from "./choices"
44
import debounce from "lodash/debounce"
55

@@ -138,6 +138,7 @@ export default class Autocomplete {
138138
wrapperElButton.addEventListener('shown.bs.dropdown', () => {
139139
choicesJS.input.element.focus()
140140
})
141+
syncDropdownMenuWidth(wrapperEl, wrapperElButton)
141142
} else {
142143
choicesJS.input.element.addEventListener('focus', initLoad)
143144
}

src/django_smartbase_admin/static/sb_admin/src/js/main.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import Datepicker from "./datepicker"
2727
import Range from "./range"
2828
import Sorting from "./sorting"
2929
import Autocomplete from "./autocomplete"
30+
import StaticAutocomplete from "./static_autocomplete"
3031
import ChoicesJS from "./choices"
3132
import TextTags from "./text_tags"
3233
import {setCookie, setDropdownLabel} from "./utils"
@@ -73,6 +74,7 @@ class Main {
7374
this.initDropdowns(detail.target)
7475
this.initInputs(detail.target)
7576
this.autocomplete.handleDynamiclyAddedAutocomplete(detail.target)
77+
this.staticAutocomplete.handleDynamicallyAdded(detail.target)
7678
this.textTags.handleDynamicallyAddedTextTags(detail.target)
7779
this.initInlines(detail.target)
7880
this.initTooltips(detail.target)
@@ -90,8 +92,9 @@ class Main {
9092
this.initInputs()
9193
new Sorting()
9294
this.autocomplete = new Autocomplete()
95+
this.staticAutocomplete = new StaticAutocomplete()
9396
this.textTags = new TextTags()
94-
new ChoicesJS()
97+
this.choicesJS = new ChoicesJS()
9598
document.addEventListener('click', (e) => {
9699
this.closeAlert(e)
97100
this.selectAll(e)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import Choices from "choices.js"
2+
import {choicesJSOptions} from "./choices"
3+
import {createIcon, getResultLabel, syncDropdownMenuWidth} from "./utils"
4+
5+
// Shares the autocomplete UI shell (dropdown button + Choices.js wrapped
6+
// <select>) but uses a static list of <option> tags rendered server-side —
7+
// no API fetch, no pagination, no add-new, no hidden JSON input. The <select>
8+
// submits natively, so Django's default MultipleChoiceField/ChoiceField
9+
// value_from_datadict handles the POST with no custom widget parsing.
10+
export default class StaticAutocomplete {
11+
constructor() {
12+
this.handleDynamicallyAdded(document)
13+
document.addEventListener('formset:added', (event) => {
14+
this.handleDynamicallyAdded(event.target)
15+
})
16+
}
17+
18+
handleDynamicallyAdded(root) {
19+
root.querySelectorAll('.js-static-autocomplete').forEach((choiceInput) => {
20+
this.init(choiceInput)
21+
})
22+
}
23+
24+
init(choiceInput) {
25+
if (choiceInput.closest('.choices')) return
26+
const wrapperEl = document.getElementById(`${choiceInput.id}-wrapper`)
27+
if (!wrapperEl) return
28+
const wrapperElButton = wrapperEl.querySelector('button[data-bs-toggle="dropdown"]')
29+
const deleteButton = wrapperEl.querySelector('.js-clear-autocomplete')
30+
const labelEl = document.getElementById(`${choiceInput.id}-value`)
31+
const emptyLabel = labelEl ? labelEl.textContent.trim() : ''
32+
33+
const choicesJS = new Choices(choiceInput, {
34+
...choicesJSOptions(choiceInput),
35+
placeholderValue: window.sb_admin_translation_strings?.["search"] || 'Search',
36+
searchPlaceholderValue: window.sb_admin_translation_strings?.["search"] || 'Search',
37+
noResultsText: window.sb_admin_translation_strings?.["no_results"] || 'No results found',
38+
noChoicesText: window.sb_admin_translation_strings?.["no_choices"] || 'No choices to choose from',
39+
searchEnabled: true,
40+
searchChoices: true,
41+
searchResultLimit: 999,
42+
// Default renderSelectedChoices ('auto'): selected items leave the
43+
// dropdown and show as removable pills above it — matches dynamic
44+
// multi-select autocomplete behavior.
45+
callbackOnInit: () => {
46+
const label = document.createElement('label')
47+
label.appendChild(createIcon('Find', []))
48+
choiceInput.parentElement.appendChild(label)
49+
},
50+
})
51+
52+
const updateLabel = () => {
53+
if (!labelEl) return
54+
const value = choicesJS.getValue()
55+
const items = Array.isArray(value) ? value : (value ? [value] : [])
56+
labelEl.innerHTML = items.length === 0 ? emptyLabel : getResultLabel(items)
57+
}
58+
choiceInput.addEventListener('addItem', updateLabel)
59+
choiceInput.addEventListener('removeItem', updateLabel)
60+
updateLabel()
61+
62+
if (!choiceInput.hasAttribute('multiple')) {
63+
// Single-select: close the dropdown after picking.
64+
choiceInput.addEventListener('change', () => {
65+
if (wrapperElButton) wrapperElButton.click()
66+
})
67+
}
68+
69+
if (wrapperElButton) {
70+
wrapperElButton.addEventListener('shown.bs.dropdown', () => {
71+
choicesJS.input.element.focus()
72+
})
73+
syncDropdownMenuWidth(wrapperEl, wrapperElButton)
74+
}
75+
76+
if (deleteButton) {
77+
deleteButton.addEventListener('click', () => {
78+
choicesJS.removeActiveItems(null)
79+
updateLabel()
80+
})
81+
}
82+
83+
// Render with the search input visible immediately — the whole point
84+
// of the static widget is its client-side search.
85+
choicesJS.containerOuter.element.classList.add('search-on')
86+
choicesJS.input.element.focus()
87+
}
88+
}

src/django_smartbase_admin/static/sb_admin/src/js/utils.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const filterInputValueChangeListener = (inputSelector, callbackFunction)
7171
})
7272
}
7373

74-
const getResultLabel = (valueOrObject, separator=', ') => {
74+
export const getResultLabel = (valueOrObject, separator=', ') => {
7575
const labelArray = []
7676
const entries = Object.values(valueOrObject)
7777
let hasMaxEntries = false
@@ -117,6 +117,24 @@ export const setDropdownLabel = (dropdownMenuEl, dropdownLabelEl) => {
117117
dropdownLabelEl.innerHTML = getResultLabel(fields)
118118
}
119119

120+
export const syncDropdownMenuWidth = (wrapperEl, wrapperElButton) => {
121+
// Wrapper opts in via data-autocomplete-full-width="true": the dropdown
122+
// menu is resized to match the wrapper (and kept in sync on resize while
123+
// it's open). Used by both the dynamic autocomplete and the static
124+
// Choices.js dropdowns.
125+
if (wrapperEl.dataset.autocompleteFullWidth !== 'true') return
126+
const menuEl = wrapperEl.querySelector('.dropdown-menu')
127+
if (!menuEl) return
128+
const syncMenuWidth = () => {
129+
menuEl.style.width = `${wrapperEl.offsetWidth}px`
130+
}
131+
wrapperElButton.addEventListener('show.bs.dropdown', syncMenuWidth)
132+
wrapperElButton.addEventListener('shown.bs.dropdown', syncMenuWidth)
133+
window.addEventListener('resize', () => {
134+
if (menuEl.classList.contains('show')) syncMenuWidth()
135+
})
136+
}
137+
120138
export const filterInputValueChangedUtil = (field) => {
121139
const filterId = field.dataset.filterId || field.id
122140
const separator = field.dataset.labelSeparator || ', '

src/django_smartbase_admin/templates/sb_admin/widgets/autocomplete.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
{% with autocomplete_data_id=filter_widget.view_id|add:"-"|add:filter_widget.input_id|add:"_data" %}
33
<div class="relative">
44
{% include 'sb_admin/widgets/includes/field_label.html' %}
5-
<div id="{{ widget.attrs.id }}-wrapper" class="relative flex items-center max-w-full overflow-hidden{% if widget.attrs.related_edit_url or widget.attrs.related_add_url %} gap-4{% endif %}">
5+
<div id="{{ widget.attrs.id }}-wrapper"
6+
{% if filter_widget.full_width %}data-autocomplete-full-width="true"{% endif %}
7+
class="relative flex items-center max-w-full overflow-hidden{% if widget.attrs.related_edit_url or widget.attrs.related_add_url %} gap-4{% endif %}">
68
<button
79
data-bs-toggle="dropdown"
810
aria-expanded="false"
@@ -18,7 +20,7 @@
1820
{% endblock %}
1921
</button>
2022
{% include 'sb_admin/widgets/includes/related_item_buttons.html' %}
21-
<div class="dropdown-menu max-h-none w-248">
23+
<div class="dropdown-menu max-h-none{% if filter_widget.full_width %} w-auto{% else %} w-248{% endif %}">
2224
<div class="px-12 pt-8">
2325
{% include 'sb_admin/widgets/input.html' %}
2426
<select {% if filter_widget.multiselect %}multiple{% endif %} class="js-autocomplete"
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{% load i18n %}
2+
<div class="relative">
3+
{% include 'sb_admin/widgets/includes/field_label.html' %}
4+
<div id="{{ widget.attrs.id }}-wrapper"
5+
{% if widget.full_width %}data-autocomplete-full-width="true"{% endif %}
6+
class="relative flex items-center max-w-full overflow-hidden">
7+
<button
8+
type="button"
9+
data-bs-toggle="dropdown"
10+
aria-expanded="false"
11+
data-bs-offset="[0, 8]"
12+
data-bs-config='{"popperConfig":{"strategy":"fixed"}}'
13+
class="autocomplete-button btn px-10 font-normal flex-1 min-w-0"
14+
>
15+
<span id="{{ widget.attrs.id }}-value">{% trans 'Select' %}</span>
16+
<svg class="ml-8">
17+
<use xlink:href="#Down"></use>
18+
</svg>
19+
</button>
20+
<div class="dropdown-menu max-h-none{% if widget.full_width %} w-auto{% else %} w-248{% endif %}">
21+
<div class="px-12 pt-8">
22+
<select {% if widget.attrs.multiple %}multiple{% endif %}
23+
id="{{ widget.attrs.id }}"
24+
name="{{ widget.name }}"
25+
class="js-static-autocomplete">
26+
{% for group, options, index in widget.optgroups %}
27+
{% for option in options %}
28+
<option value="{{ option.value|stringformat:'s' }}"{% if option.selected %} selected{% endif %}>{{ option.label }}</option>
29+
{% endfor %}
30+
{% endfor %}
31+
</select>
32+
</div>
33+
{% if widget.form_field.required %}
34+
<div class="pb-8"></div>
35+
{% else %}
36+
<div class="relative px-12 py-8 flex">
37+
<button type="button"
38+
class="text-primary js-clear-autocomplete">
39+
{% trans 'Clear' %}
40+
</button>
41+
</div>
42+
{% endif %}
43+
</div>
44+
</div>
45+
</div>
46+
{% include 'sb_admin/widgets/includes/help_text.html' %}

0 commit comments

Comments
 (0)