mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-10-31 00:53:08 +00:00 
			
		
		
		
	Remove ajax_select from counters
This commit is contained in:
		
							
								
								
									
										30
									
								
								club/static/webpack/club/components/ajax-select-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								club/static/webpack/club/components/ajax-select-index.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										25
									
								
								club/widgets/select.py
									
									
									
									
									
										Normal 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", | ||||||
|  |     ] | ||||||
							
								
								
									
										15
									
								
								core/api.py
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								core/api.py
									
									
									
									
									
								
							| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										184
									
								
								core/static/webpack/core/components/ajax-select-base.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								core/static/webpack/core/components/ajax-select-base.ts
									
									
									
									
									
										Normal 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]); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -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 { | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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() |  | ||||||
|   | |||||||
| @@ -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 |         } | ||||||
|     ) |  | ||||||
|   | |||||||
| @@ -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
									
								
							
							
						
						
									
										35
									
								
								counter/widgets/select.py
									
									
									
									
									
										Normal 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 | ||||||
		Reference in New Issue
	
	Block a user