Create select widget based on tomselect on django backend

Replace make_ajax in elections by the new widget
This commit is contained in:
Antoine Bartuccio 2024-10-19 21:32:58 +02:00
parent 0a0092e189
commit 8be8328830
5 changed files with 103 additions and 60 deletions

View File

@ -13,12 +13,18 @@ import { type UserProfileSchema, userSearchUsers } from "#openapi";
@registerComponent("autocomplete-select") @registerComponent("autocomplete-select")
class AutocompleteSelect extends inheritHtmlElement("select") { class AutocompleteSelect extends inheritHtmlElement("select") {
static observedAttributes = ["delay", "placeholder", "max"]; static observedAttributes = [
"delay",
"placeholder",
"max",
"min-characters-for-search",
];
public widget: TomSelect; public widget: TomSelect;
private delay: number | null = null; protected minCharNumberForSearch = 0;
private placeholder = ""; protected delay: number | null = null;
private max: number | null = null; protected placeholder = "";
protected max: number | null = null;
protected attributeChangedCallback( protected attributeChangedCallback(
name: string, name: string,
@ -38,26 +44,59 @@ class AutocompleteSelect extends inheritHtmlElement("select") {
this.max = Number.parseInt(newValue) ?? null; this.max = Number.parseInt(newValue) ?? null;
break; break;
} }
case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
break;
}
default: { default: {
return; return;
} }
} }
} }
constructor() { connectedCallback() {
super(); super.connectedCallback();
// Collect all options nodes and put them into the select node
window.addEventListener("DOMContentLoaded", () => { const options: Element[] = []; // We need to make a copy to delete while iterating
for (const child of this.children) {
if (child.tagName.toLowerCase() === "option") {
options.push(child);
}
}
for (const option of options) {
this.removeChild(option);
this.node.appendChild(option);
}
this.widget = new TomSelect(this.node, this.tomSelectSettings()); this.widget = new TomSelect(this.node, this.tomSelectSettings());
this.attachBehaviors(); this.attachBehaviors();
});
} }
protected tomSelectSettings(): RecursivePartial<TomSettings> { protected tomSelectSettings(): RecursivePartial<TomSettings> {
return { return {
maxItems: this.max, maxItems: this.node.multiple ? this.max : 1,
loadThrottle: this.delay, loadThrottle: this.delay,
placeholder: this.placeholder, placeholder: this.placeholder,
shouldLoad: (query: string) => {
return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than 2 characters
},
render: {
option: (item: TomOption, sanitize: typeof escape_html) => {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.text)}</span>
</div>`;
},
item: (item: TomOption, sanitize: typeof escape_html) => {
return `<span><i class="fa fa-times"></i>${sanitize(item.text)}</span>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: this.minCharNumberForSearch - data.input.length }, true)}</div>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
no_results: (_data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${gettext("No results found")}</div>`;
},
},
}; };
} }
@ -76,23 +115,8 @@ class AutocompleteSelect extends inheritHtmlElement("select") {
@registerComponent("user-ajax-select") @registerComponent("user-ajax-select")
export class UserAjaxSelect extends AutocompleteSelect { export class UserAjaxSelect extends AutocompleteSelect {
public filter?: <T>(items: T[]) => T[]; public filter?: <T>(items: T[]) => T[];
static observedAttributes = [
"min-characters-for-search",
...AutocompleteSelect.observedAttributes,
];
private minCharNumberForSearch = 2; protected minCharNumberForSearch = 2;
protected attributeChangedCallback(
name: string,
_oldValue?: string,
newValue?: string,
) {
super.attributeChangedCallback(name, _oldValue, newValue);
if (name === "min-characters-for-search") {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
}
}
protected tomSelectSettings(): RecursivePartial<TomSettings> { protected tomSelectSettings(): RecursivePartial<TomSettings> {
return { return {
@ -103,9 +127,6 @@ export class UserAjaxSelect extends AutocompleteSelect {
valueField: "id", valueField: "id",
labelField: "display_name", labelField: "display_name",
searchField: [], // Disable local search filter and rely on tested backend searchField: [], // Disable local search filter and rely on tested backend
shouldLoad: (query: string) => {
return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than 2 characters
},
load: (query: string, callback: TomLoadCallback) => { load: (query: string, callback: TomLoadCallback) => {
userSearchUsers({ userSearchUsers({
query: { query: {
@ -124,6 +145,7 @@ export class UserAjaxSelect extends AutocompleteSelect {
}); });
}, },
render: { render: {
...super.tomSelectSettings().render,
option: (item: UserProfileSchema, sanitize: typeof escape_html) => { option: (item: UserProfileSchema, sanitize: typeof escape_html) => {
return `<div class="select-item"> return `<div class="select-item">
<img <img
@ -137,14 +159,6 @@ export class UserAjaxSelect extends AutocompleteSelect {
item: (item: UserProfileSchema, sanitize: typeof escape_html) => { item: (item: UserProfileSchema, sanitize: typeof escape_html) => {
return `<span><i class="fa fa-times"></i>${sanitize(item.display_name)}</span>`; return `<span><i class="fa fa-times"></i>${sanitize(item.display_name)}</span>`;
}, },
// biome-ignore lint/style/useNamingConvention: that's how it's defined
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: this.minCharNumberForSearch - data.input.length }, true)}</div>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
no_results: (_data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${gettext("No results found")}</div>`;
},
}, },
}; };
} }

View File

@ -0,0 +1,10 @@
<script-once src="{{ statics.js }}" defer></script-once>
{% for css in statics.csss %}
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
{% endfor %}
<autocomplete-select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
<optgroup label="{{ group_name }}">{% endif %}{% for widget in group_choices %}
{% include widget.template_name %}{% endfor %}{% if group_name %}
</optgroup>{% endif %}{% endfor %}
</autocomplete-select>

View File

@ -36,9 +36,11 @@ from django.forms import (
CheckboxSelectMultiple, CheckboxSelectMultiple,
DateInput, DateInput,
DateTimeInput, DateTimeInput,
SelectMultiple,
Textarea, Textarea,
TextInput, TextInput,
) )
from django.forms.widgets import Select
from django.utils.translation import gettext from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -72,12 +74,37 @@ class MarkdownInput(Textarea):
context = super().get_context(name, value, attrs) context = super().get_context(name, value, attrs)
context["statics"] = { context["statics"] = {
"js": staticfiles_storage.url("webpack/easymde-index.ts"), "js": staticfiles_storage.url("webpack/core/components/easymde-index.ts"),
"css": staticfiles_storage.url("webpack/easymde-index.css"), "css": staticfiles_storage.url("webpack/core/components/easymde-index.css"),
} }
return context return context
class AutoCompleteSelectMixin:
template_name = "core/widgets/autocomplete_select.jinja"
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["statics"] = {
"js": staticfiles_storage.url(
"webpack/core/components/ajax-select-index.ts"
),
"csss": [
staticfiles_storage.url(
"webpack/core/components/ajax-select-index.css"
),
staticfiles_storage.url("core/components/ajax-select.scss"),
],
}
return context
class AutoCompleteSelect(AutoCompleteSelectMixin, Select): ...
class AutoCompleteSelectMultiple(AutoCompleteSelectMixin, SelectMultiple): ...
class NFCTextInput(TextInput): class NFCTextInput(TextInput):
template_name = "core/widgets/nfc.jinja" template_name = "core/widgets/nfc.jinja"

View File

@ -1,6 +1,5 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@ -13,7 +12,11 @@ from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.views import CanCreateMixin, CanEditMixin, CanViewMixin from core.views import CanCreateMixin, CanEditMixin, CanViewMixin
from core.views.forms import MarkdownInput, SelectDateTime from core.views.forms import (
AutoCompleteSelectMultiple,
MarkdownInput,
SelectDateTime,
)
from election.models import Candidature, Election, ElectionList, Role, Vote from election.models import Candidature, Election, ElectionList, Role, Vote
if TYPE_CHECKING: if TYPE_CHECKING:
@ -146,23 +149,12 @@ class ElectionForm(forms.ModelForm):
"vote_groups", "vote_groups",
"candidature_groups", "candidature_groups",
] ]
widgets = {
edit_groups = make_ajax_field( "edit_groups": AutoCompleteSelectMultiple,
Election, "edit_groups", "groups", help_text="", label=_("edit groups") "view_groups": AutoCompleteSelectMultiple,
) "vote_groups": AutoCompleteSelectMultiple,
view_groups = make_ajax_field( "candidature_groups": AutoCompleteSelectMultiple,
Election, "view_groups", "groups", help_text="", label=_("view groups") }
)
vote_groups = make_ajax_field(
Election, "vote_groups", "groups", help_text="", label=_("vote groups")
)
candidature_groups = make_ajax_field(
Election,
"candidature_groups",
"groups",
help_text="",
label=_("candidature groups"),
)
start_date = forms.DateTimeField( start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True label=_("Start date"), widget=SelectDateTime, required=True