diff --git a/club/static/webpack/club/components/ajax-select-index.ts b/club/static/webpack/club/components/ajax-select-index.ts new file mode 100644 index 00000000..d58de56c --- /dev/null +++ b/club/static/webpack/club/components/ajax-select-index.ts @@ -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 { + const resp = await clubSearchClub({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: ClubSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.name)} +
`; + } + + protected renderItem(item: ClubSchema, sanitize: typeof escape_html) { + return `${sanitize(item.name)}`; + } +} diff --git a/club/widgets/select.py b/club/widgets/select.py new file mode 100644 index 00000000..3c96af05 --- /dev/null +++ b/club/widgets/select.py @@ -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", + ] diff --git a/core/api.py b/core/api.py index b8f22a3e..445d79d2 100644 --- a/core/api.py +++ b/core/api.py @@ -15,9 +15,10 @@ from core.api_permissions import ( CanAccessLookup, CanView, ) -from core.models import SithFile, User +from core.models import Group, SithFile, User from core.schemas import ( FamilyGodfatherSchema, + GroupSchema, MarkdownSchema, SithFileSchema, UserFamilySchema, @@ -78,6 +79,18 @@ class SithFileController(ControllerBase): 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)] DEFAULT_DEPTH = 4 diff --git a/core/static/webpack/core/components/ajax-select-base.ts b/core/static/webpack/core/components/ajax-select-base.ts new file mode 100644 index 00000000..de3fdfa8 --- /dev/null +++ b/core/static/webpack/core/components/ajax-select-base.ts @@ -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 { + 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 `
+ ${sanitize(item.text)} +
`; + }, + item: (item: TomOption, sanitize: typeof escape_html) => { + return `${sanitize(item.text)}`; + }, + // biome-ignore lint/style/useNamingConvention: that's how it's defined + not_loading: (data: TomOption, _sanitize: typeof escape_html) => { + return `
${interpolate(gettext("You need to type %(number)s more characters"), { number: this.minCharNumberForSearch - data.input.length }, true)}
`; + }, + // biome-ignore lint/style/useNamingConvention: that's how it's defined + no_results: (_data: TomOption, _sanitize: typeof escape_html) => { + return `
${gettext("No results found")}
`; + }, + }, + }; + } + + 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; + + 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 { + 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 `json` + for (const value of this.initialValues) { + this.widget.addOption(value, false); + this.widget.addItem(value[this.valueField]); + } + } +} diff --git a/core/static/webpack/core/components/ajax-select-index.ts b/core/static/webpack/core/components/ajax-select-index.ts index 4ba1e091..4aabe69f 100644 --- a/core/static/webpack/core/components/ajax-select-index.ts +++ b/core/static/webpack/core/components/ajax-select-index.ts @@ -1,12 +1,6 @@ import "tom-select/dist/css/tom-select.default.css"; -import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; -import TomSelect from "tom-select"; -import type { - RecursivePartial, - TomLoadCallback, - TomOption, - TomSettings, -} from "tom-select/dist/types/types"; +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 GroupSchema, @@ -15,181 +9,13 @@ import { userSearchUsers, } from "#openapi"; +import { + AjaxSelect, + AutoCompleteSelectBase, +} from "#core:core/components/ajax-select-base"; + @registerComponent("autocomplete-select") -class AutocompleteSelect 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 { - 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 `
- ${sanitize(item.text)} -
`; - }, - item: (item: TomOption, sanitize: typeof escape_html) => { - return `${sanitize(item.text)}`; - }, - // biome-ignore lint/style/useNamingConvention: that's how it's defined - not_loading: (data: TomOption, _sanitize: typeof escape_html) => { - return `
${interpolate(gettext("You need to type %(number)s more characters"), { number: this.minCharNumberForSearch - data.input.length }, true)}
`; - }, - // biome-ignore lint/style/useNamingConvention: that's how it's defined - no_results: (_data: TomOption, _sanitize: typeof escape_html) => { - return `
${gettext("No results found")}
`; - }, - }, - }; - } - - 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; - - 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 { - 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 `json` - for (const value of this.initialValues) { - this.widget.addOption(value, false); - this.widget.addItem(value[this.valueField]); - } - } -} +export class AutoCompleteSelect extends AutoCompleteSelectBase {} @registerComponent("user-ajax-select") export class UserAjaxSelect extends AjaxSelect { diff --git a/core/views/widgets/select.py b/core/views/widgets/select.py index abd9732d..cd951940 100644 --- a/core/views/widgets/select.py +++ b/core/views/widgets/select.py @@ -49,7 +49,15 @@ class AutoCompleteSelectMixin: context["selected"] = [ self.schema.from_orm(obj).json() 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() ] return context diff --git a/counter/api.py b/counter/api.py index 851fdb24..f3f0f101 100644 --- a/counter/api.py +++ b/counter/api.py @@ -22,8 +22,6 @@ from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema 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.schemas import ( CounterFilterSchema, @@ -78,15 +76,3 @@ class ProductController(ControllerBase): .filter(archived=False) .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() diff --git a/counter/forms.py b/counter/forms.py index c43de059..0945bdb7 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,10 +1,15 @@ -from ajax_select import make_ajax_field -from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField from django import forms from django.utils.translation import gettext_lazy as _ from phonenumber_field.widgets import RegionalPhoneNumberWidget +from club.widgets.select import AutoCompleteSelectClub from core.views.forms import NFCTextInput, SelectDate, SelectDateTime +from core.views.widgets.select import ( + AutoCompleteSelect, + AutoCompleteSelectMultipleGroup, + AutoCompleteSelectMultipleUser, + AutoCompleteSelectUser, +) from counter.models import ( BillingInfo, Counter, @@ -14,6 +19,11 @@ from counter.models import ( Refilling, StudentCard, ) +from counter.widgets.select import ( + AutoCompleteSelectMultipleCounter, + AutoCompleteSelectMultipleProduct, + AutoCompleteSelectProduct, +) class BillingInfoForm(forms.ModelForm): @@ -68,8 +78,11 @@ class GetUserForm(forms.Form): required=False, widget=NFCTextInput, ) - id = AutoCompleteSelectField( - "users", required=False, label=_("Select user"), help_text=None + id = forms.CharField( + label=_("Select user"), + help_text=None, + widget=AutoCompleteSelectUser, + required=False, ) def as_p(self): @@ -122,8 +135,10 @@ class CounterEditForm(forms.ModelForm): model = Counter fields = ["sellers", "products"] - sellers = make_ajax_field(Counter, "sellers", "users", help_text="") - products = make_ajax_field(Counter, "products", "products", help_text="") + widgets = { + "sellers": AutoCompleteSelectMultipleUser, + "products": AutoCompleteSelectMultipleProduct, + } class ProductEditForm(forms.ModelForm): @@ -145,44 +160,37 @@ class ProductEditForm(forms.ModelForm): "tray", "archived", ] + widgets = { + "parent_product": AutoCompleteSelectMultipleProduct, + "product_type": AutoCompleteSelect, + "buying_groups": AutoCompleteSelectMultipleGroup, + "club": AutoCompleteSelectClub, + } - parent_product = AutoCompleteSelectField( - "products", show_help_text=False, label=_("Parent product"), required=False - ) - 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="", + counters = forms.ModelMultipleChoiceField( + help_text=None, label=_("Counters"), required=False, + widget=AutoCompleteSelectMultipleCounter, + queryset=Counter.objects.all(), ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance.id: - self.fields["counters"].initial = [ - str(c.id) for c in self.instance.counters.all() - ] + self.fields["counters"].initial = self.instance.counters.all() def save(self, *args, **kwargs): ret = super().save(*args, **kwargs) if self.fields["counters"].initial: - for cid in self.fields["counters"].initial: - c = Counter.objects.filter(id=int(cid)).first() - c.products.remove(self.instance) - c.save() - for cid in self.cleaned_data["counters"]: - c = Counter.objects.filter(id=int(cid)).first() - c.products.add(self.instance) - c.save() + # Remove the product from all counter it was added to + # It will then only be added to selected counters + for counter in self.fields["counters"].initial: + counter.products.remove(self.instance) + counter.save() + for counter in self.cleaned_data["counters"]: + counter.products.add(self.instance) + counter.save() return ret @@ -199,8 +207,7 @@ class EticketForm(forms.ModelForm): class Meta: model = Eticket fields = ["product", "banner", "event_title", "event_date"] - widgets = {"event_date": SelectDate} - - product = AutoCompleteSelectField( - "products", show_help_text=False, label=_("Product"), required=True - ) + widgets = { + "product": AutoCompleteSelectProduct, + "event_date": SelectDate, + } diff --git a/counter/static/webpack/counter/components/ajax-select-index.ts b/counter/static/webpack/counter/components/ajax-select-index.ts new file mode 100644 index 00000000..147e4733 --- /dev/null +++ b/counter/static/webpack/counter/components/ajax-select-index.ts @@ -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 { + const resp = await productSearchProducts({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: ProductSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.code)} - ${sanitize(item.name)} +
`; + } + + protected renderItem(item: ProductSchema, sanitize: typeof escape_html) { + return `${sanitize(item.code)} - ${sanitize(item.name)}`; + } +} + +@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 { + const resp = await counterSearchCounter({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: CounterSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.name)} +
`; + } + + protected renderItem(item: CounterSchema, sanitize: typeof escape_html) { + return `${sanitize(item.name)}`; + } +} diff --git a/counter/widgets/select.py b/counter/widgets/select.py new file mode 100644 index 00000000..4b1bdf4a --- /dev/null +++ b/counter/widgets/select.py @@ -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