Remove ajax_select from counters

This commit is contained in:
Antoine Bartuccio 2024-10-20 20:55:05 +02:00
parent 125157fdf4
commit 7f8a2c1eaf
10 changed files with 409 additions and 235 deletions

View File

@ -0,0 +1,30 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")
export class ClubAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: ClubSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ClubSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

25
club/widgets/select.py Normal file
View File

@ -0,0 +1,25 @@
from django.forms import Select, SelectMultiple
from club.models import Club
from club.schemas import ClubSchema
from core.views.widgets.select import AutoCompleteSelectMixin
class AutoCompleteSelectClub(AutoCompleteSelectMixin, Select):
component_name = "club-ajax-select"
model = Club
schema = ClubSchema
js = [
"webpack/club/components/ajax-select-index.ts",
]
class AutoCompleteSelectMultipleClub(AutoCompleteSelectMixin, SelectMultiple):
component_name = "club-ajax-select"
model = Club
schema = ClubSchema
js = [
"webpack/club/components/ajax-select-index.ts",
]

View File

@ -15,9 +15,10 @@ from core.api_permissions import (
CanAccessLookup, CanAccessLookup,
CanView, CanView,
) )
from core.models import SithFile, User from core.models import Group, SithFile, User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema, MarkdownSchema,
SithFileSchema, SithFileSchema,
UserFamilySchema, UserFamilySchema,
@ -78,6 +79,18 @@ class SithFileController(ControllerBase):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=query) return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=query)
@api_controller("/group")
class GroupController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[GroupSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)] DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DEFAULT_DEPTH = 4 DEFAULT_DEPTH = 4

View File

@ -0,0 +1,184 @@
import { inheritHtmlElement } from "#core:utils/web-components";
import TomSelect from "tom-select";
import type {
RecursivePartial,
TomLoadCallback,
TomOption,
TomSettings,
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
"delay",
"placeholder",
"max",
"min-characters-for-search",
];
public widget: TomSelect;
protected minCharNumberForSearch = 0;
protected delay: number | null = null;
protected placeholder = "";
protected max: number | null = null;
protected attributeChangedCallback(
name: string,
_oldValue?: string,
newValue?: string,
) {
switch (name) {
case "delay": {
this.delay = Number.parseInt(newValue) ?? null;
break;
}
case "placeholder": {
this.placeholder = newValue ?? "";
break;
}
case "max": {
this.max = Number.parseInt(newValue) ?? null;
break;
}
case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
break;
}
default: {
return;
}
}
}
connectedCallback() {
super.connectedCallback();
this.widget = new TomSelect(this.node, this.tomSelectSettings());
this.attachBehaviors();
}
protected shouldLoad(query: string) {
return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than setup number of characters
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
return {
plugins: {
// biome-ignore lint/style/useNamingConvention: this is required by the api
remove_button: {
title: gettext("Remove"),
},
},
persist: false,
maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true,
loadThrottle: this.delay,
placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
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>${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>`;
},
},
};
}
protected attachBehaviors() {
/* Called once the widget has been initialized */
}
}
export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected minCharNumberForSearch = 2;
protected abstract valueField: string;
protected abstract labelField: string;
protected abstract searchField: string[];
protected abstract renderOption(
item: TomOption,
sanitize: typeof escape_html,
): string;
protected abstract renderItem(item: TomOption, sanitize: typeof escape_html): string;
protected abstract search(query: string): Promise<TomOption[]>;
private initialValues: TomOption[] = [];
public setFilter(filter?: (items: TomOption[]) => TomOption[]) {
this.filter = filter;
}
protected shouldLoad(query: string) {
const resp = super.shouldLoad(query);
/* Force order sync with backend if no client side filtering is set */
if (!resp && this.searchField.length === 0) {
this.widget.clearOptions();
}
return resp;
}
protected async loadFunction(query: string, callback: TomLoadCallback) {
/* Force order sync with backend if no client side filtering is set */
if (this.searchField.length === 0) {
this.widget.clearOptions();
}
const resp = await this.search(query);
if (this.filter) {
callback(this.filter(resp), []);
} else {
callback(resp, []);
}
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
return {
...super.tomSelectSettings(),
hideSelected: true,
diacritics: true,
duplicates: false,
valueField: this.valueField,
labelField: this.labelField,
searchField: this.searchField,
load: (query: string, callback: TomLoadCallback) =>
this.loadFunction(query, callback), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
...super.tomSelectSettings().render,
option: this.renderOption,
item: this.renderItem,
},
};
}
connectedCallback() {
/* Capture initial values before they get moved to the inner node and overridden by tom-select */
this.initialValues = Array.from(this.children)
.filter((child: Element) => child.tagName.toLowerCase() === "slot")
.map((slot) => JSON.parse(slot.innerHTML));
super.connectedCallback();
}
protected attachBehaviors() {
super.attachBehaviors();
// Gather selected options, they must be added with slots like `<slot>json</slot>`
for (const value of this.initialValues) {
this.widget.addOption(value, false);
this.widget.addItem(value[this.valueField]);
}
}
}

View File

@ -1,12 +1,6 @@
import "tom-select/dist/css/tom-select.default.css"; import "tom-select/dist/css/tom-select.default.css";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { registerComponent } from "#core:utils/web-components";
import TomSelect from "tom-select"; import type { TomOption } from "tom-select/dist/types/types";
import type {
RecursivePartial,
TomLoadCallback,
TomOption,
TomSettings,
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/dist/types/utils";
import { import {
type GroupSchema, type GroupSchema,
@ -15,181 +9,13 @@ import {
userSearchUsers, userSearchUsers,
} from "#openapi"; } from "#openapi";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
@registerComponent("autocomplete-select") @registerComponent("autocomplete-select")
class AutocompleteSelect extends inheritHtmlElement("select") { export class AutoCompleteSelect extends AutoCompleteSelectBase {}
static observedAttributes = [
"delay",
"placeholder",
"max",
"min-characters-for-search",
];
public widget: TomSelect;
protected minCharNumberForSearch = 0;
protected delay: number | null = null;
protected placeholder = "";
protected max: number | null = null;
protected attributeChangedCallback(
name: string,
_oldValue?: string,
newValue?: string,
) {
switch (name) {
case "delay": {
this.delay = Number.parseInt(newValue) ?? null;
break;
}
case "placeholder": {
this.placeholder = newValue ?? "";
break;
}
case "max": {
this.max = Number.parseInt(newValue) ?? null;
break;
}
case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
break;
}
default: {
return;
}
}
}
connectedCallback() {
super.connectedCallback();
this.widget = new TomSelect(this.node, this.tomSelectSettings());
this.attachBehaviors();
}
protected shouldLoad(query: string) {
return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than setup number of characters
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
return {
plugins: {
// biome-ignore lint/style/useNamingConvention: this is required by the api
remove_button: {
title: gettext("Remove"),
},
},
persist: false,
maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true,
loadThrottle: this.delay,
placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
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>${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>`;
},
},
};
}
protected attachBehaviors() {
/* Called once the widget has been initialized */
}
}
abstract class AjaxSelect extends AutocompleteSelect {
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected minCharNumberForSearch = 2;
protected abstract valueField: string;
protected abstract labelField: string;
protected abstract searchField: string[];
protected abstract renderOption(
item: TomOption,
sanitize: typeof escape_html,
): string;
protected abstract renderItem(item: TomOption, sanitize: typeof escape_html): string;
protected abstract search(query: string): Promise<TomOption[]>;
private initialValues: TomOption[] = [];
public setFilter(filter?: (items: TomOption[]) => TomOption[]) {
this.filter = filter;
}
protected shouldLoad(query: string) {
const resp = super.shouldLoad(query);
/* Force order sync with backend if no client side filtering is set */
if (!resp && this.searchField.length === 0) {
this.widget.clearOptions();
}
return resp;
}
protected async loadFunction(query: string, callback: TomLoadCallback) {
/* Force order sync with backend if no client side filtering is set */
if (this.searchField.length === 0) {
this.widget.clearOptions();
}
const resp = await this.search(query);
if (this.filter) {
callback(this.filter(resp), []);
} else {
callback(resp, []);
}
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
return {
...super.tomSelectSettings(),
hideSelected: true,
diacritics: true,
duplicates: false,
valueField: this.valueField,
labelField: this.labelField,
searchField: this.searchField,
load: (query: string, callback: TomLoadCallback) =>
this.loadFunction(query, callback), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
...super.tomSelectSettings().render,
option: this.renderOption,
item: this.renderItem,
},
};
}
connectedCallback() {
/* Capture initial values before they get moved to the inner node and overridden by tom-select */
this.initialValues = Array.from(this.children)
.filter((child) => child.tagName.toLowerCase() === "slot")
.map((slot) => JSON.parse(slot.innerHTML));
super.connectedCallback();
}
protected attachBehaviors() {
super.attachBehaviors();
// Gather selected options, they must be added with slots like `<slot>json</slot>`
for (const value of this.initialValues) {
this.widget.addOption(value, false);
this.widget.addItem(value[this.valueField]);
}
}
}
@registerComponent("user-ajax-select") @registerComponent("user-ajax-select")
export class UserAjaxSelect extends AjaxSelect { export class UserAjaxSelect extends AjaxSelect {

View File

@ -49,7 +49,15 @@ class AutoCompleteSelectMixin:
context["selected"] = [ context["selected"] = [
self.schema.from_orm(obj).json() self.schema.from_orm(obj).json()
for obj in self.model.objects.filter( for obj in self.model.objects.filter(
**{f"{self.pk}__in": context["widget"]["value"]} **{
f"{self.pk}__in": [
pk
for pk in context["widget"]["value"]
if str(
pk
).isdigit() # We filter empty values for create views
]
}
).all() ).all()
] ]
return context return context

View File

@ -22,8 +22,6 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsRoot from core.api_permissions import CanAccessLookup, CanView, IsRoot
from core.models import Group
from core.schemas import GroupSchema
from counter.models import Counter, Product from counter.models import Counter, Product
from counter.schemas import ( from counter.schemas import (
CounterFilterSchema, CounterFilterSchema,
@ -78,15 +76,3 @@ class ProductController(ControllerBase):
.filter(archived=False) .filter(archived=False)
.values() .values()
) )
@api_controller("/group")
class GroupController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[GroupSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).values()

View File

@ -1,10 +1,15 @@
from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
from django import forms from django import forms
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
from club.widgets.select import AutoCompleteSelectClub
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
from core.views.widgets.select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from counter.models import ( from counter.models import (
BillingInfo, BillingInfo,
Counter, Counter,
@ -14,6 +19,11 @@ from counter.models import (
Refilling, Refilling,
StudentCard, StudentCard,
) )
from counter.widgets.select import (
AutoCompleteSelectMultipleCounter,
AutoCompleteSelectMultipleProduct,
AutoCompleteSelectProduct,
)
class BillingInfoForm(forms.ModelForm): class BillingInfoForm(forms.ModelForm):
@ -68,8 +78,11 @@ class GetUserForm(forms.Form):
required=False, required=False,
widget=NFCTextInput, widget=NFCTextInput,
) )
id = AutoCompleteSelectField( id = forms.CharField(
"users", required=False, label=_("Select user"), help_text=None label=_("Select user"),
help_text=None,
widget=AutoCompleteSelectUser,
required=False,
) )
def as_p(self): def as_p(self):
@ -122,8 +135,10 @@ class CounterEditForm(forms.ModelForm):
model = Counter model = Counter
fields = ["sellers", "products"] fields = ["sellers", "products"]
sellers = make_ajax_field(Counter, "sellers", "users", help_text="") widgets = {
products = make_ajax_field(Counter, "products", "products", help_text="") "sellers": AutoCompleteSelectMultipleUser,
"products": AutoCompleteSelectMultipleProduct,
}
class ProductEditForm(forms.ModelForm): class ProductEditForm(forms.ModelForm):
@ -145,44 +160,37 @@ class ProductEditForm(forms.ModelForm):
"tray", "tray",
"archived", "archived",
] ]
widgets = {
"parent_product": AutoCompleteSelectMultipleProduct,
"product_type": AutoCompleteSelect,
"buying_groups": AutoCompleteSelectMultipleGroup,
"club": AutoCompleteSelectClub,
}
parent_product = AutoCompleteSelectField( counters = forms.ModelMultipleChoiceField(
"products", show_help_text=False, label=_("Parent product"), required=False help_text=None,
)
buying_groups = AutoCompleteSelectMultipleField(
"groups",
show_help_text=False,
help_text="",
label=_("Buying groups"),
required=True,
)
club = AutoCompleteSelectField("clubs", show_help_text=False)
counters = AutoCompleteSelectMultipleField(
"counters",
show_help_text=False,
help_text="",
label=_("Counters"), label=_("Counters"),
required=False, required=False,
widget=AutoCompleteSelectMultipleCounter,
queryset=Counter.objects.all(),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance.id: if self.instance.id:
self.fields["counters"].initial = [ self.fields["counters"].initial = self.instance.counters.all()
str(c.id) for c in self.instance.counters.all()
]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs) ret = super().save(*args, **kwargs)
if self.fields["counters"].initial: if self.fields["counters"].initial:
for cid in self.fields["counters"].initial: # Remove the product from all counter it was added to
c = Counter.objects.filter(id=int(cid)).first() # It will then only be added to selected counters
c.products.remove(self.instance) for counter in self.fields["counters"].initial:
c.save() counter.products.remove(self.instance)
for cid in self.cleaned_data["counters"]: counter.save()
c = Counter.objects.filter(id=int(cid)).first() for counter in self.cleaned_data["counters"]:
c.products.add(self.instance) counter.products.add(self.instance)
c.save() counter.save()
return ret return ret
@ -199,8 +207,7 @@ class EticketForm(forms.ModelForm):
class Meta: class Meta:
model = Eticket model = Eticket
fields = ["product", "banner", "event_title", "event_date"] fields = ["product", "banner", "event_title", "event_date"]
widgets = {"event_date": SelectDate} widgets = {
"product": AutoCompleteSelectProduct,
product = AutoCompleteSelectField( "event_date": SelectDate,
"products", show_help_text=False, label=_("Product"), required=True }
)

View File

@ -0,0 +1,60 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type CounterSchema,
type ProductSchema,
counterSearchCounter,
productSearchProducts,
} from "#openapi";
@registerComponent("product-ajax-select")
export class ProductAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await productSearchProducts({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: ProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ProductSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
}
}
@registerComponent("counter-ajax-select")
export class CounterAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await counterSearchCounter({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: CounterSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: CounterSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}

35
counter/widgets/select.py Normal file
View File

@ -0,0 +1,35 @@
from django.forms import Select, SelectMultiple
from core.views.widgets.select import AutoCompleteSelectMixin
from counter.models import Counter, Product
from counter.schemas import ProductSchema, SimplifiedCounterSchema
class CounterAutoCompleteSelectMixin(AutoCompleteSelectMixin):
js = [
"webpack/counter/components/ajax-select-index.ts",
]
class AutoCompleteSelectCounter(CounterAutoCompleteSelectMixin, Select):
component_name = "counter-ajax-select"
model = Counter
schema = SimplifiedCounterSchema
class AutoCompleteSelectMultipleCounter(CounterAutoCompleteSelectMixin, SelectMultiple):
component_name = "counter-ajax-select"
model = Counter
schema = SimplifiedCounterSchema
class AutoCompleteSelectProduct(CounterAutoCompleteSelectMixin, Select):
component_name = "product-ajax-select"
model = Product
schema = ProductSchema
class AutoCompleteSelectMultipleProduct(CounterAutoCompleteSelectMixin, SelectMultiple):
component_name = "product-ajax-select"
model = Product
schema = ProductSchema