From 56cc4776a6490b611da08294c55ccf79be7a8afc Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 18 Oct 2024 23:26:04 +0200 Subject: [PATCH 01/33] Create base class for ajax-select --- core/static/webpack/ajax-select-index.ts | 89 +++++++++++++++++------- sas/templates/sas/picture.jinja | 8 +-- 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/core/static/webpack/ajax-select-index.ts b/core/static/webpack/ajax-select-index.ts index e64df216..c98fc48b 100644 --- a/core/static/webpack/ajax-select-index.ts +++ b/core/static/webpack/ajax-select-index.ts @@ -1,41 +1,89 @@ import "tom-select/dist/css/tom-select.css"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import TomSelect from "tom-select"; -import type { TomItem, TomLoadCallback, TomOption } from "tom-select/dist/types/types"; +import type { + RecursivePartial, + TomItem, + TomLoadCallback, + TomOption, + TomSettings, +} from "tom-select/dist/types/types"; import type { escape_html } from "tom-select/dist/types/utils"; import { type UserProfileSchema, userSearchUsers } from "#openapi"; -@registerComponent("ajax-select") -export class AjaxSelect extends inheritHtmlElement("select") { +abstract class AjaxSelectBase extends inheritHtmlElement("select") { + static observedAttributes = ["delay", "placeholder", "max"]; public widget: TomSelect; - public filter?: (items: T[]) => T[]; + + private delay: number | null = null; + private placeholder = ""; + private max: number | null = null; + + 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; + } + default: { + return; + } + } + } constructor() { super(); window.addEventListener("DOMContentLoaded", () => { - this.loadTomSelect(); + this.configureTomSelect(this.defaultTomSelectSettings()); + this.setDefaultTomSelectBehaviors(); }); } - loadTomSelect() { + private defaultTomSelectSettings(): RecursivePartial { + return { + maxItems: this.max, + loadThrottle: this.delay, + placeholder: this.placeholder, + }; + } + + private setDefaultTomSelectBehaviors() { + // Allow removing selected items by clicking on them + this.widget.on("item_select", (item: TomItem) => { + this.widget.removeItem(item); + }); + // Remove typed text once an item has been selected + this.widget.on("item_add", () => { + this.widget.setTextboxValue(""); + }); + } + + abstract configureTomSelect(defaultSettings: RecursivePartial): void; +} + +@registerComponent("user-ajax-select") +export class UserAjaxSelect extends AjaxSelectBase { + public filter?: (items: T[]) => T[]; + + configureTomSelect(defaultSettings: RecursivePartial) { const minCharNumberForSearch = 2; - let maxItems = 1; - - if (this.node.multiple) { - maxItems = Number.parseInt(this.node.dataset.max) ?? null; - } - this.widget = new TomSelect(this.node, { + ...defaultSettings, hideSelected: true, diacritics: true, duplicates: false, - maxItems: maxItems, - loadThrottle: Number.parseInt(this.node.dataset.delay) ?? null, valueField: "id", labelField: "display_name", - searchField: ["display_name", "nick_name", "first_name", "last_name"], - placeholder: this.node.dataset.placeholder ?? "", + searchField: [], // Disable local search filter and rely on tested backend shouldLoad: (query: string) => { return query.length >= minCharNumberForSearch; // Avoid launching search with less than 2 characters }, @@ -80,14 +128,5 @@ export class AjaxSelect extends inheritHtmlElement("select") { }, }, }); - - // Allow removing selected items by clicking on them - this.widget.on("item_select", (item: TomItem) => { - this.widget.removeItem(item); - }); - // Remove typed text once an item has been selected - this.widget.on("item_add", () => { - this.widget.setTextboxValue(""); - }); } } diff --git a/sas/templates/sas/picture.jinja b/sas/templates/sas/picture.jinja index 43651383..965164fa 100644 --- a/sas/templates/sas/picture.jinja +++ b/sas/templates/sas/picture.jinja @@ -157,12 +157,12 @@
{% trans %}People{% endtrans %}
{% if user.was_subscribed %}
- + delay="300" + placeholder="{%- trans -%}Identify users on pictures{%- endtrans -%}" + >
{% endif %} From 729f848c1495683dc47e5f8ee01cf102744480af Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 18 Oct 2024 23:34:37 +0200 Subject: [PATCH 02/33] Add min-characters-for-search attribute for user-ajax-select --- core/static/webpack/ajax-select-index.ts | 28 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/core/static/webpack/ajax-select-index.ts b/core/static/webpack/ajax-select-index.ts index c98fc48b..6816d2f5 100644 --- a/core/static/webpack/ajax-select-index.ts +++ b/core/static/webpack/ajax-select-index.ts @@ -19,7 +19,11 @@ abstract class AjaxSelectBase extends inheritHtmlElement("select") { private placeholder = ""; private max: number | null = null; - attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) { + protected attributeChangedCallback( + name: string, + _oldValue?: string, + newValue?: string, + ) { switch (name) { case "delay": { this.delay = Number.parseInt(newValue) ?? null; @@ -73,9 +77,25 @@ abstract class AjaxSelectBase extends inheritHtmlElement("select") { @registerComponent("user-ajax-select") export class UserAjaxSelect extends AjaxSelectBase { public filter?: (items: T[]) => T[]; + static observedAttributes = [ + "min-characters-for-search", + ...AjaxSelectBase.observedAttributes, + ]; + + private 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; + } + } configureTomSelect(defaultSettings: RecursivePartial) { - const minCharNumberForSearch = 2; this.widget = new TomSelect(this.node, { ...defaultSettings, hideSelected: true, @@ -85,7 +105,7 @@ export class UserAjaxSelect extends AjaxSelectBase { labelField: "display_name", searchField: [], // Disable local search filter and rely on tested backend shouldLoad: (query: string) => { - return query.length >= minCharNumberForSearch; // Avoid launching search with less than 2 characters + return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than 2 characters }, load: (query: string, callback: TomLoadCallback) => { userSearchUsers({ @@ -120,7 +140,7 @@ export class UserAjaxSelect extends AjaxSelectBase { }, // 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: minCharNumberForSearch - data.input.length }, true)}
`; + 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) => { From 6b3012d21c0347966e165161ab21c9d70576971a Mon Sep 17 00:00:00 2001 From: Sli Date: Fri, 18 Oct 2024 23:50:04 +0200 Subject: [PATCH 03/33] Fix broken sas ui in webkit based browsers --- core/static/core/components/ajax-select.scss | 44 ++++++++++++++ core/static/core/style.scss | 57 ------------------- .../components}/ajax-select-index.ts | 0 sas/templates/sas/picture.jinja | 7 ++- webpack.config.js | 4 +- 5 files changed, 51 insertions(+), 61 deletions(-) create mode 100644 core/static/core/components/ajax-select.scss rename core/static/webpack/{ => core/components}/ajax-select-index.ts (100%) diff --git a/core/static/core/components/ajax-select.scss b/core/static/core/components/ajax-select.scss new file mode 100644 index 00000000..8005b52d --- /dev/null +++ b/core/static/core/components/ajax-select.scss @@ -0,0 +1,44 @@ +/* This also requires ajax-select-index.css */ +.tomselected { + .select2-container--default { + color: black; + } +} + +.ts-dropdown { + + .select-item { + display: flex; + flex-direction: row; + gap: 10px; + align-items: center; + + img { + height: 40px; + width: 40px; + object-fit: cover; + border-radius: 50%; + } + } +} + +.ts-control { + + .item { + .fa-times { + margin-left: 5px; + margin-right: 5px; + } + + cursor: pointer; + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + display: inline-block; + margin-left: 5px; + margin-top: 5px; + margin-bottom: 5px; + padding-right: 10px; + } + +} \ No newline at end of file diff --git a/core/static/core/style.scss b/core/static/core/style.scss index d658d957..21481454 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -712,63 +712,6 @@ a:not(.button) { } } - -.tomselected { - margin: 10px 0 !important; - max-width: 100%; - min-width: 100%; - - ul { - margin: 0; - } - - textarea { - background-color: inherit; - } - - .select2-container--default { - color: black; - } -} - -.ts-dropdown { - - .select-item { - display: flex; - flex-direction: row; - gap: 10px; - align-items: center; - - img { - height: 40px; - width: 40px; - object-fit: cover; - border-radius: 50%; - } - } -} - -.ts-control { - - .item { - .fa-times { - margin-left: 5px; - margin-right: 5px; - } - - cursor: pointer; - background-color: #e4e4e4; - border: 1px solid #aaa; - border-radius: 4px; - display: inline-block; - margin-left: 5px; - margin-top: 5px; - margin-bottom: 5px; - padding-right: 10px; - } - -} - #news_details { display: inline-block; margin-top: 20px; diff --git a/core/static/webpack/ajax-select-index.ts b/core/static/webpack/core/components/ajax-select-index.ts similarity index 100% rename from core/static/webpack/ajax-select-index.ts rename to core/static/webpack/core/components/ajax-select-index.ts diff --git a/sas/templates/sas/picture.jinja b/sas/templates/sas/picture.jinja index 965164fa..c14fe72b 100644 --- a/sas/templates/sas/picture.jinja +++ b/sas/templates/sas/picture.jinja @@ -1,12 +1,13 @@ {% extends "core/base.jinja" %} {%- block additional_css -%} - - + + + {%- endblock -%} {%- block additional_js -%} - + {%- endblock -%} diff --git a/webpack.config.js b/webpack.config.js index ca2b7046..5ff49e1f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,7 +10,7 @@ module.exports = { .sync("./!(static)/static/webpack/**/*?(-)index.[j|t]s?(x)") .reduce((obj, el) => { // We include the path inside the webpack folder in the name - const relativePath = []; + let relativePath = []; const fullPath = path.parse(el); for (const dir of fullPath.dir.split("/").reverse()) { if (dir === "webpack") { @@ -18,6 +18,8 @@ module.exports = { } relativePath.push(dir); } + // We collected folders in reverse order, we put them back in the original order + relativePath = relativePath.reverse(); relativePath.push(fullPath.name); obj[relativePath.join("/")] = `./${el}`; return obj; From c50f0a2ac505c1f1d0739ef9feaa60b0c0c8c2f5 Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 19 Oct 2024 16:02:54 +0200 Subject: [PATCH 04/33] Simplify ajax-select inheritance and make simple auto complete --- .../core/components/ajax-select-index.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/core/static/webpack/core/components/ajax-select-index.ts b/core/static/webpack/core/components/ajax-select-index.ts index 6816d2f5..37915f7a 100644 --- a/core/static/webpack/core/components/ajax-select-index.ts +++ b/core/static/webpack/core/components/ajax-select-index.ts @@ -11,7 +11,8 @@ import type { import type { escape_html } from "tom-select/dist/types/utils"; import { type UserProfileSchema, userSearchUsers } from "#openapi"; -abstract class AjaxSelectBase extends inheritHtmlElement("select") { +@registerComponent("autocomplete-select") +class AutocompleteSelect extends inheritHtmlElement("select") { static observedAttributes = ["delay", "placeholder", "max"]; public widget: TomSelect; @@ -47,12 +48,12 @@ abstract class AjaxSelectBase extends inheritHtmlElement("select") { super(); window.addEventListener("DOMContentLoaded", () => { - this.configureTomSelect(this.defaultTomSelectSettings()); - this.setDefaultTomSelectBehaviors(); + this.widget = new TomSelect(this.node, this.tomSelectSettings()); + this.attachBehaviors(); }); } - private defaultTomSelectSettings(): RecursivePartial { + protected tomSelectSettings(): RecursivePartial { return { maxItems: this.max, loadThrottle: this.delay, @@ -60,7 +61,7 @@ abstract class AjaxSelectBase extends inheritHtmlElement("select") { }; } - private setDefaultTomSelectBehaviors() { + protected attachBehaviors() { // Allow removing selected items by clicking on them this.widget.on("item_select", (item: TomItem) => { this.widget.removeItem(item); @@ -70,16 +71,14 @@ abstract class AjaxSelectBase extends inheritHtmlElement("select") { this.widget.setTextboxValue(""); }); } - - abstract configureTomSelect(defaultSettings: RecursivePartial): void; } @registerComponent("user-ajax-select") -export class UserAjaxSelect extends AjaxSelectBase { +export class UserAjaxSelect extends AutocompleteSelect { public filter?: (items: T[]) => T[]; static observedAttributes = [ "min-characters-for-search", - ...AjaxSelectBase.observedAttributes, + ...AutocompleteSelect.observedAttributes, ]; private minCharNumberForSearch = 2; @@ -95,9 +94,9 @@ export class UserAjaxSelect extends AjaxSelectBase { } } - configureTomSelect(defaultSettings: RecursivePartial) { - this.widget = new TomSelect(this.node, { - ...defaultSettings, + protected tomSelectSettings(): RecursivePartial { + return { + ...super.tomSelectSettings(), hideSelected: true, diacritics: true, duplicates: false, @@ -147,6 +146,6 @@ export class UserAjaxSelect extends AjaxSelectBase { return `
${gettext("No results found")}
`; }, }, - }); + }; } } From 0a0092e1896243111b2dcae70de4fbcf249cf926 Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 19 Oct 2024 17:54:34 +0200 Subject: [PATCH 05/33] Add link-once and script-once web components --- .../webpack/core/components/include-index.ts | 33 +++++++++++++++++++ core/static/webpack/easymde-index.ts | 6 ++-- core/static/webpack/utils/web-components.ts | 8 +++-- core/templates/core/base.jinja | 1 + .../core/widgets/markdown_textarea.jinja | 10 +++--- 5 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 core/static/webpack/core/components/include-index.ts diff --git a/core/static/webpack/core/components/include-index.ts b/core/static/webpack/core/components/include-index.ts new file mode 100644 index 00000000..ea8dbb6b --- /dev/null +++ b/core/static/webpack/core/components/include-index.ts @@ -0,0 +1,33 @@ +import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; + +/** + * Web component used to import css files only once + * If called multiple times or the file was already imported, it does nothing + **/ +@registerComponent("link-once") +export class LinkOnce extends inheritHtmlElement("link") { + connectedCallback() { + super.connectedCallback(false); + // We get href from node.attributes instead of node.href to avoid getting the domain part + const href = this.node.attributes.getNamedItem("href").nodeValue; + if (document.querySelectorAll(`link[href='${href}']`).length === 0) { + this.appendChild(this.node); + } + } +} + +/** + * Web component used to import javascript files only once + * If called multiple times or the file was already imported, it does nothing + **/ +@registerComponent("script-once") +export class ScriptOnce extends inheritHtmlElement("script") { + connectedCallback() { + super.connectedCallback(false); + // We get src from node.attributes instead of node.src to avoid getting the domain part + const src = this.node.attributes.getNamedItem("src").nodeValue; + if (document.querySelectorAll(`script[src='${src}']`).length === 0) { + this.appendChild(this.node); + } + } +} diff --git a/core/static/webpack/easymde-index.ts b/core/static/webpack/easymde-index.ts index 04a33f17..734aa9ec 100644 --- a/core/static/webpack/easymde-index.ts +++ b/core/static/webpack/easymde-index.ts @@ -185,8 +185,8 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => { @registerComponent("markdown-input") class MarkdownInput extends inheritHtmlElement("textarea") { - constructor() { - super(); - window.addEventListener("DOMContentLoaded", () => loadEasyMde(this.node)); + connectedCallback() { + super.connectedCallback(); + loadEasyMde(this.node); } } diff --git a/core/static/webpack/utils/web-components.ts b/core/static/webpack/utils/web-components.ts index 2899a5af..f6949731 100644 --- a/core/static/webpack/utils/web-components.ts +++ b/core/static/webpack/utils/web-components.ts @@ -30,8 +30,7 @@ export function inheritHtmlElement(tagNam return class Inherited extends HTMLElement { protected node: HTMLElementTagNameMap[K]; - constructor() { - super(); + connectedCallback(autoAddNode?: boolean) { this.node = document.createElement(tagName); const attributes: Attr[] = []; // We need to make a copy to delete while iterating for (const attr of this.attributes) { @@ -44,7 +43,10 @@ export function inheritHtmlElement(tagNam this.removeAttributeNode(attr); this.node.setAttributeNode(attr); } - this.appendChild(this.node); + // Atuomatically add node to DOM if autoAddNode is true or unspecified + if (autoAddNode === undefined || autoAddNode) { + this.appendChild(this.node); + } } }; } diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja index 8ce2eb80..fbd4c997 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -21,6 +21,7 @@ + diff --git a/core/templates/core/widgets/markdown_textarea.jinja b/core/templates/core/widgets/markdown_textarea.jinja index 287e4521..1131d5bd 100644 --- a/core/templates/core/widgets/markdown_textarea.jinja +++ b/core/templates/core/widgets/markdown_textarea.jinja @@ -1,7 +1,7 @@
- {% if widget.value %}{{ widget.value }}{% endif %} + + - {# The easymde script can be included twice, it's safe in the code #} - - -
+ {% if widget.value %}{{ widget.value }}{% endif %} + + From 8be83288305fbf4416f076069ce65405b0797912 Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 19 Oct 2024 21:32:58 +0200 Subject: [PATCH 06/33] Create select widget based on tomselect on django backend Replace make_ajax in elections by the new widget --- .../core/components/ajax-select-index.ts | 92 +++++++++++-------- .../{ => core/components}/easymde-index.ts | 0 .../core/widgets/autocomplete_select.jinja | 10 ++ core/views/forms.py | 31 ++++++- election/views.py | 30 +++--- 5 files changed, 103 insertions(+), 60 deletions(-) rename core/static/webpack/{ => core/components}/easymde-index.ts (100%) create mode 100644 core/templates/core/widgets/autocomplete_select.jinja diff --git a/core/static/webpack/core/components/ajax-select-index.ts b/core/static/webpack/core/components/ajax-select-index.ts index 37915f7a..1db8f8b8 100644 --- a/core/static/webpack/core/components/ajax-select-index.ts +++ b/core/static/webpack/core/components/ajax-select-index.ts @@ -13,12 +13,18 @@ import { type UserProfileSchema, userSearchUsers } from "#openapi"; @registerComponent("autocomplete-select") class AutocompleteSelect extends inheritHtmlElement("select") { - static observedAttributes = ["delay", "placeholder", "max"]; + static observedAttributes = [ + "delay", + "placeholder", + "max", + "min-characters-for-search", + ]; public widget: TomSelect; - private delay: number | null = null; - private placeholder = ""; - private max: number | null = null; + protected minCharNumberForSearch = 0; + protected delay: number | null = null; + protected placeholder = ""; + protected max: number | null = null; protected attributeChangedCallback( name: string, @@ -38,26 +44,59 @@ class AutocompleteSelect extends inheritHtmlElement("select") { this.max = Number.parseInt(newValue) ?? null; break; } + case "min-characters-for-search": { + this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0; + break; + } default: { return; } } } - constructor() { - super(); - - window.addEventListener("DOMContentLoaded", () => { - this.widget = new TomSelect(this.node, this.tomSelectSettings()); - this.attachBehaviors(); - }); + connectedCallback() { + super.connectedCallback(); + // Collect all options nodes and put them into the select node + 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.attachBehaviors(); } protected tomSelectSettings(): RecursivePartial { return { - maxItems: this.max, + maxItems: this.node.multiple ? this.max : 1, loadThrottle: this.delay, 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 `
+ ${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")}
`; + }, + }, }; } @@ -76,23 +115,8 @@ class AutocompleteSelect extends inheritHtmlElement("select") { @registerComponent("user-ajax-select") export class UserAjaxSelect extends AutocompleteSelect { public filter?: (items: T[]) => T[]; - static observedAttributes = [ - "min-characters-for-search", - ...AutocompleteSelect.observedAttributes, - ]; - private 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 minCharNumberForSearch = 2; protected tomSelectSettings(): RecursivePartial { return { @@ -103,9 +127,6 @@ export class UserAjaxSelect extends AutocompleteSelect { valueField: "id", labelField: "display_name", 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) => { userSearchUsers({ query: { @@ -124,6 +145,7 @@ export class UserAjaxSelect extends AutocompleteSelect { }); }, render: { + ...super.tomSelectSettings().render, option: (item: UserProfileSchema, sanitize: typeof escape_html) => { return `
{ return `${sanitize(item.display_name)}`; }, - // 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")}
`; - }, }, }; } diff --git a/core/static/webpack/easymde-index.ts b/core/static/webpack/core/components/easymde-index.ts similarity index 100% rename from core/static/webpack/easymde-index.ts rename to core/static/webpack/core/components/easymde-index.ts diff --git a/core/templates/core/widgets/autocomplete_select.jinja b/core/templates/core/widgets/autocomplete_select.jinja new file mode 100644 index 00000000..19f1deae --- /dev/null +++ b/core/templates/core/widgets/autocomplete_select.jinja @@ -0,0 +1,10 @@ + +{% for css in statics.csss %} + +{% endfor %} + +{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %} + {% endif %}{% for widget in group_choices %} + {% include widget.template_name %}{% endfor %}{% if group_name %} + {% endif %}{% endfor %} + \ No newline at end of file diff --git a/core/views/forms.py b/core/views/forms.py index 232c938b..228e4cfe 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -36,9 +36,11 @@ from django.forms import ( CheckboxSelectMultiple, DateInput, DateTimeInput, + SelectMultiple, Textarea, TextInput, ) +from django.forms.widgets import Select from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from phonenumber_field.widgets import RegionalPhoneNumberWidget @@ -72,12 +74,37 @@ class MarkdownInput(Textarea): context = super().get_context(name, value, attrs) context["statics"] = { - "js": staticfiles_storage.url("webpack/easymde-index.ts"), - "css": staticfiles_storage.url("webpack/easymde-index.css"), + "js": staticfiles_storage.url("webpack/core/components/easymde-index.ts"), + "css": staticfiles_storage.url("webpack/core/components/easymde-index.css"), } 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): template_name = "core/widgets/nfc.jinja" diff --git a/election/views.py b/election/views.py index 65aaf363..c24e044b 100644 --- a/election/views.py +++ b/election/views.py @@ -1,6 +1,5 @@ from typing import TYPE_CHECKING -from ajax_select import make_ajax_field from ajax_select.fields import AutoCompleteSelectField from django import forms 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 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 if TYPE_CHECKING: @@ -146,23 +149,12 @@ class ElectionForm(forms.ModelForm): "vote_groups", "candidature_groups", ] - - edit_groups = make_ajax_field( - Election, "edit_groups", "groups", help_text="", label=_("edit groups") - ) - view_groups = make_ajax_field( - 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"), - ) + widgets = { + "edit_groups": AutoCompleteSelectMultiple, + "view_groups": AutoCompleteSelectMultiple, + "vote_groups": AutoCompleteSelectMultiple, + "candidature_groups": AutoCompleteSelectMultiple, + } start_date = forms.DateTimeField( label=_("Start date"), widget=SelectDateTime, required=True From ce4f57bd8f510d8c62760a575c272c1fcbda714c Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 19 Oct 2024 22:00:58 +0200 Subject: [PATCH 07/33] Add ajax user widget and remove ajax_select from elections --- .../core/widgets/autocomplete_select.jinja | 4 ++-- core/views/forms.py | 19 ++++++++++++++++++ election/views.py | 20 +++++++++++++------ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/core/templates/core/widgets/autocomplete_select.jinja b/core/templates/core/widgets/autocomplete_select.jinja index 19f1deae..30bc7eb5 100644 --- a/core/templates/core/widgets/autocomplete_select.jinja +++ b/core/templates/core/widgets/autocomplete_select.jinja @@ -3,8 +3,8 @@ {% endfor %} -{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %} +<{{ component }} name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %} {% endif %}{% for widget in group_choices %} {% include widget.template_name %}{% endfor %}{% if group_name %} {% endif %}{% endfor %} - \ No newline at end of file + \ No newline at end of file diff --git a/core/views/forms.py b/core/views/forms.py index 228e4cfe..9c34a003 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -81,10 +81,19 @@ class MarkdownInput(Textarea): class AutoCompleteSelectMixin: + component_name = "autocomplete-select" template_name = "core/widgets/autocomplete_select.jinja" + is_ajax = False + + def optgroups(self, name, value, attrs=None): + """Don't create option groups when doing ajax""" + if self.is_ajax: + return [] + return super().optgroups(name, value, attrs=attrs) def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) + context["component"] = self.component_name context["statics"] = { "js": staticfiles_storage.url( "webpack/core/components/ajax-select-index.ts" @@ -105,6 +114,16 @@ class AutoCompleteSelect(AutoCompleteSelectMixin, Select): ... class AutoCompleteSelectMultiple(AutoCompleteSelectMixin, SelectMultiple): ... +class AutoCompleteSelectUser(AutoCompleteSelectMixin, Select): + component_name = "user-ajax-select" + is_ajax = True + + +class AutoCompleteSelectMultipleUser(AutoCompleteSelectMixin, SelectMultiple): + component_name = "user-ajax-select" + is_ajax = True + + class NFCTextInput(TextInput): template_name = "core/widgets/nfc.jinja" diff --git a/election/views.py b/election/views.py index c24e044b..4280680a 100644 --- a/election/views.py +++ b/election/views.py @@ -1,6 +1,5 @@ from typing import TYPE_CHECKING -from ajax_select.fields import AutoCompleteSelectField from django import forms from django.core.exceptions import PermissionDenied from django.db import transaction @@ -13,7 +12,9 @@ from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateVi from core.views import CanCreateMixin, CanEditMixin, CanViewMixin from core.views.forms import ( + AutoCompleteSelect, AutoCompleteSelectMultiple, + AutoCompleteSelectUser, MarkdownInput, SelectDateTime, ) @@ -54,11 +55,15 @@ class CandidateForm(forms.ModelForm): class Meta: model = Candidature fields = ["user", "role", "program", "election_list"] - widgets = {"program": MarkdownInput} - - user = AutoCompleteSelectField( - "users", label=_("User to candidate"), help_text=None, required=True - ) + labels = { + "user": _("User to candidate"), + } + widgets = { + "program": MarkdownInput, + "user": AutoCompleteSelectUser, + "role": AutoCompleteSelect, + "election_list": AutoCompleteSelect, + } def __init__(self, *args, **kwargs): election_id = kwargs.pop("election_id", None) @@ -100,6 +105,7 @@ class RoleForm(forms.ModelForm): class Meta: model = Role fields = ["title", "election", "description", "max_choice"] + widgets = {"election": AutoCompleteSelect} def __init__(self, *args, **kwargs): election_id = kwargs.pop("election_id", None) @@ -123,6 +129,7 @@ class ElectionListForm(forms.ModelForm): class Meta: model = ElectionList fields = ("title", "election") + widgets = {"election": AutoCompleteSelect} def __init__(self, *args, **kwargs): election_id = kwargs.pop("election_id", None) @@ -320,6 +327,7 @@ class CandidatureCreateView(CanCreateMixin, CreateView): """Verify that the selected user is in candidate group.""" obj = form.instance obj.election = Election.objects.get(id=self.election.id) + obj.user = obj.user if hasattr(obj, "user") else self.request.user if (obj.election.can_candidate(obj.user)) and ( obj.user == self.request.user or self.can_edit ): From e3dcad62cc00ed31518baa3f213347445bb15ba2 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 20 Oct 2024 00:18:53 +0200 Subject: [PATCH 08/33] Migrates lookups * products * files * Groups * Clubs * Accounting --- accounting/api.py | 23 ++++++++++++++++ accounting/schemas.py | 15 ++++++++++ club/api.py | 22 +++++++++++++++ club/schemas.py | 9 ++++++ core/api.py | 22 +++++++++++++-- core/api_permissions.py | 5 +++- core/lookups.py | 18 ++++++------ core/schemas.py | 14 +++++++++- counter/api.py | 61 ++++++++++++++++++++++++++++++++++++++--- counter/schemas.py | 23 ++++++++++++++-- 10 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 accounting/api.py create mode 100644 accounting/schemas.py create mode 100644 club/api.py create mode 100644 club/schemas.py diff --git a/accounting/api.py b/accounting/api.py new file mode 100644 index 00000000..a16fb7ab --- /dev/null +++ b/accounting/api.py @@ -0,0 +1,23 @@ +from typing import Annotated + +from annotated_types import MinLen +from ninja_extra import ControllerBase, api_controller, paginate, route +from ninja_extra.pagination import PageNumberPaginationExtra +from ninja_extra.schemas import PaginatedResponseSchema + +from accounting.models import ClubAccount, Company +from accounting.schemas import ClubAccountSchema, CompanySchema +from core.api_permissions import CanAccessLookup + + +@api_controller("/lookup", permissions=[CanAccessLookup]) +class AccountingController(ControllerBase): + @route.get("/club-account", response=PaginatedResponseSchema[ClubAccountSchema]) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_club_account(self, search: Annotated[str, MinLen(1)]): + return ClubAccount.objects.filter(name__icontains=search).values() + + @route.get("/company", response=PaginatedResponseSchema[CompanySchema]) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_company(self, search: Annotated[str, MinLen(1)]): + return Company.objects.filter(name__icontains=search).values() diff --git a/accounting/schemas.py b/accounting/schemas.py new file mode 100644 index 00000000..3d9edbcc --- /dev/null +++ b/accounting/schemas.py @@ -0,0 +1,15 @@ +from ninja import ModelSchema + +from accounting.models import ClubAccount, Company + + +class ClubAccountSchema(ModelSchema): + class Meta: + model = ClubAccount + fields = ["id", "name"] + + +class CompanySchema(ModelSchema): + class Meta: + model = Company + fields = ["id", "name"] diff --git a/club/api.py b/club/api.py new file mode 100644 index 00000000..9a680154 --- /dev/null +++ b/club/api.py @@ -0,0 +1,22 @@ +from typing import Annotated + +from annotated_types import MinLen +from ninja_extra import ControllerBase, api_controller, paginate, route +from ninja_extra.pagination import PageNumberPaginationExtra +from ninja_extra.schemas import PaginatedResponseSchema + +from club.models import Club +from club.schemas import ClubSchema +from core.api_permissions import CanAccessLookup + + +@api_controller("/club") +class ClubController(ControllerBase): + @route.get( + "/search", + response=PaginatedResponseSchema[ClubSchema], + permissions=[CanAccessLookup], + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_club(self, search: Annotated[str, MinLen(1)]): + return Club.objects.filter(name__icontains=search).values() diff --git a/club/schemas.py b/club/schemas.py new file mode 100644 index 00000000..cbd35988 --- /dev/null +++ b/club/schemas.py @@ -0,0 +1,9 @@ +from ninja import ModelSchema + +from club.models import Club + + +class ClubSchema(ModelSchema): + class Meta: + model = Club + fields = ["id", "name"] diff --git a/core/api.py b/core/api.py index 7df689bc..b8f22a3e 100644 --- a/core/api.py +++ b/core/api.py @@ -11,11 +11,15 @@ from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema from club.models import Mailing -from core.api_permissions import CanView, IsLoggedInCounter, IsOldSubscriber, IsRoot -from core.models import User +from core.api_permissions import ( + CanAccessLookup, + CanView, +) +from core.models import SithFile, User from core.schemas import ( FamilyGodfatherSchema, MarkdownSchema, + SithFileSchema, UserFamilySchema, UserFilterSchema, UserProfileSchema, @@ -44,7 +48,7 @@ class MailingListController(ControllerBase): return data -@api_controller("/user", permissions=[IsOldSubscriber | IsRoot | IsLoggedInCounter]) +@api_controller("/user", permissions=[CanAccessLookup]) class UserController(ControllerBase): @route.get("", response=list[UserProfileSchema]) def fetch_profiles(self, pks: Query[set[int]]): @@ -62,6 +66,18 @@ class UserController(ControllerBase): ) +@api_controller("/file") +class SithFileController(ControllerBase): + @route.get( + "/search", + response=PaginatedResponseSchema[SithFileSchema], + permissions=[CanAccessLookup], + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_files(self, query: Annotated[str, annotated_types.MinLen(1)]): + return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=query) + + DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)] DEFAULT_DEPTH = 4 diff --git a/core/api_permissions.py b/core/api_permissions.py index 9ef26164..f4da67af 100644 --- a/core/api_permissions.py +++ b/core/api_permissions.py @@ -127,9 +127,12 @@ class IsLoggedInCounter(BasePermission): """Check that a user is logged in a counter.""" def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: - if "/counter/" not in request.META["HTTP_REFERER"]: + if "/counter/" not in request.META.get("HTTP_REFERER", ""): return False token = request.session.get("counter_token") if not token: return False return Counter.objects.filter(token=token).exists() + + +CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter diff --git a/core/lookups.py b/core/lookups.py index 93d15df8..fc650efa 100644 --- a/core/lookups.py +++ b/core/lookups.py @@ -30,7 +30,7 @@ class RightManagedLookupChannel(LookupChannel): raise PermissionDenied -@register("users") +@register("users") # Migrated class UsersLookup(RightManagedLookupChannel): model = User @@ -44,7 +44,7 @@ class UsersLookup(RightManagedLookupChannel): return item.get_display_name() -@register("customers") +@register("customers") # Never used class CustomerLookup(RightManagedLookupChannel): model = Customer @@ -58,7 +58,7 @@ class CustomerLookup(RightManagedLookupChannel): return f"{obj.user.get_display_name()} ({obj.account_id})" -@register("groups") +@register("groups") # Migrated class GroupsLookup(RightManagedLookupChannel): model = Group @@ -72,7 +72,7 @@ class GroupsLookup(RightManagedLookupChannel): return item.name -@register("clubs") +@register("clubs") # Migrated class ClubLookup(RightManagedLookupChannel): model = Club @@ -86,7 +86,7 @@ class ClubLookup(RightManagedLookupChannel): return item.name -@register("counters") +@register("counters") # Migrated class CountersLookup(RightManagedLookupChannel): model = Counter @@ -97,7 +97,7 @@ class CountersLookup(RightManagedLookupChannel): return item.name -@register("products") +@register("products") # Migrated class ProductsLookup(RightManagedLookupChannel): model = Product @@ -111,7 +111,7 @@ class ProductsLookup(RightManagedLookupChannel): return "%s (%s)" % (item.name, item.code) -@register("files") +@register("files") # Migrated class SithFileLookup(RightManagedLookupChannel): model = SithFile @@ -119,7 +119,7 @@ class SithFileLookup(RightManagedLookupChannel): return self.model.objects.filter(name__icontains=q)[:50] -@register("club_accounts") +@register("club_accounts") # Migrated class ClubAccountLookup(RightManagedLookupChannel): model = ClubAccount @@ -130,7 +130,7 @@ class ClubAccountLookup(RightManagedLookupChannel): return item.name -@register("companies") +@register("companies") # Migrated class CompaniesLookup(RightManagedLookupChannel): model = Company diff --git a/core/schemas.py b/core/schemas.py index 386a326f..d5c46ea7 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -8,7 +8,7 @@ from haystack.query import SearchQuerySet from ninja import FilterSchema, ModelSchema, Schema from pydantic import AliasChoices, Field -from core.models import User +from core.models import Group, SithFile, User class SimpleUserSchema(ModelSchema): @@ -45,6 +45,18 @@ class UserProfileSchema(ModelSchema): return obj.profile_pict.get_download_url() +class SithFileSchema(ModelSchema): + class Meta: + model = SithFile + fields = ["id", "name"] + + +class GroupSchema(ModelSchema): + class Meta: + model = Group + fields = ["id", "name"] + + class UserFilterSchema(FilterSchema): search: Annotated[str, MinLen(1)] exclude: list[int] | None = Field( diff --git a/counter/api.py b/counter/api.py index 834852d4..851fdb24 100644 --- a/counter/api.py +++ b/counter/api.py @@ -12,11 +12,25 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from ninja_extra import ControllerBase, api_controller, route +from typing import Annotated -from core.api_permissions import CanView, IsRoot -from counter.models import Counter -from counter.schemas import CounterSchema +from annotated_types import MinLen +from django.db.models import Q +from ninja import Query +from ninja_extra import ControllerBase, api_controller, paginate, route +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, + CounterSchema, + ProductSchema, + SimplifiedCounterSchema, +) @api_controller("/counter") @@ -37,3 +51,42 @@ class CounterController(ControllerBase): for c in counters: self.check_object_permissions(c) return counters + + @route.get( + "/search", + response=PaginatedResponseSchema[SimplifiedCounterSchema], + permissions=[CanAccessLookup], + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_counter(self, filters: Query[CounterFilterSchema]): + return filters.filter(Counter.objects.all()) + + +@api_controller("/product") +class ProductController(ControllerBase): + @route.get( + "/search", + response=PaginatedResponseSchema[ProductSchema], + permissions=[CanAccessLookup], + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_products(self, search: Annotated[str, MinLen(1)]): + return ( + Product.objects.filter( + Q(name__icontains=search) | Q(code__icontains=search) + ) + .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/schemas.py b/counter/schemas.py index afe2455d..ec1a842d 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -1,7 +1,10 @@ -from ninja import ModelSchema +from typing import Annotated + +from annotated_types import MinLen +from ninja import Field, FilterSchema, ModelSchema from core.schemas import SimpleUserSchema -from counter.models import Counter +from counter.models import Counter, Product class CounterSchema(ModelSchema): @@ -11,3 +14,19 @@ class CounterSchema(ModelSchema): class Meta: model = Counter fields = ["id", "name", "type", "club", "products"] + + +class CounterFilterSchema(FilterSchema): + search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains") + + +class SimplifiedCounterSchema(ModelSchema): + class Meta: + model = Counter + fields = ["id", "name"] + + +class ProductSchema(ModelSchema): + class Meta: + model = Product + fields = ["id", "name", "code"] From f78b968075a662ba9f8dab6099d922cce93f5e03 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 20 Oct 2024 01:05:34 +0200 Subject: [PATCH 09/33] Move markdown input and select widgets to a widget folder --- com/views.py | 3 +- core/views/forms.py | 61 ---------------------------------- core/views/page.py | 3 +- core/views/widgets/markdown.py | 15 +++++++++ core/views/widgets/select.py | 46 +++++++++++++++++++++++++ election/views.py | 6 ++-- forum/views.py | 2 +- pedagogy/forms.py | 2 +- 8 files changed, 70 insertions(+), 68 deletions(-) create mode 100644 core/views/widgets/markdown.py create mode 100644 core/views/widgets/select.py diff --git a/com/views.py b/com/views.py index f9ea78eb..69ee7221 100644 --- a/com/views.py +++ b/com/views.py @@ -51,7 +51,8 @@ from core.views import ( QuickNotifMixin, TabedViewMixin, ) -from core.views.forms import MarkdownInput, SelectDateTime +from core.views.forms import SelectDateTime +from core.views.widgets.markdown import MarkdownInput # Sith object diff --git a/core/views/forms.py b/core/views/forms.py index 9c34a003..d1597c2c 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -29,18 +29,14 @@ from captcha.fields import CaptchaField from django import forms from django.conf import settings from django.contrib.auth.forms import AuthenticationForm, UserCreationForm -from django.contrib.staticfiles.storage import staticfiles_storage from django.core.exceptions import ValidationError from django.db import transaction from django.forms import ( CheckboxSelectMultiple, DateInput, DateTimeInput, - SelectMultiple, - Textarea, TextInput, ) -from django.forms.widgets import Select from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from phonenumber_field.widgets import RegionalPhoneNumberWidget @@ -67,63 +63,6 @@ class SelectDate(DateInput): super().__init__(attrs=attrs, format=format or "%Y-%m-%d") -class MarkdownInput(Textarea): - template_name = "core/widgets/markdown_textarea.jinja" - - def get_context(self, name, value, attrs): - context = super().get_context(name, value, attrs) - - context["statics"] = { - "js": staticfiles_storage.url("webpack/core/components/easymde-index.ts"), - "css": staticfiles_storage.url("webpack/core/components/easymde-index.css"), - } - return context - - -class AutoCompleteSelectMixin: - component_name = "autocomplete-select" - template_name = "core/widgets/autocomplete_select.jinja" - is_ajax = False - - def optgroups(self, name, value, attrs=None): - """Don't create option groups when doing ajax""" - if self.is_ajax: - return [] - return super().optgroups(name, value, attrs=attrs) - - def get_context(self, name, value, attrs): - context = super().get_context(name, value, attrs) - context["component"] = self.component_name - 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 AutoCompleteSelectUser(AutoCompleteSelectMixin, Select): - component_name = "user-ajax-select" - is_ajax = True - - -class AutoCompleteSelectMultipleUser(AutoCompleteSelectMixin, SelectMultiple): - component_name = "user-ajax-select" - is_ajax = True - - class NFCTextInput(TextInput): template_name = "core/widgets/nfc.jinja" diff --git a/core/views/page.py b/core/views/page.py index 01fd59f6..e33e84ba 100644 --- a/core/views/page.py +++ b/core/views/page.py @@ -23,7 +23,8 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView from core.models import LockError, Page, PageRev from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin -from core.views.forms import MarkdownInput, PageForm, PagePropForm +from core.views.forms import PageForm, PagePropForm +from core.views.widgets.markdown import MarkdownInput class CanEditPagePropMixin(CanEditPropMixin): diff --git a/core/views/widgets/markdown.py b/core/views/widgets/markdown.py new file mode 100644 index 00000000..bd442b20 --- /dev/null +++ b/core/views/widgets/markdown.py @@ -0,0 +1,15 @@ +from django.contrib.staticfiles.storage import staticfiles_storage +from django.forms import Textarea + + +class MarkdownInput(Textarea): + template_name = "core/widgets/markdown_textarea.jinja" + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + + context["statics"] = { + "js": staticfiles_storage.url("webpack/core/components/easymde-index.ts"), + "css": staticfiles_storage.url("webpack/core/components/easymde-index.css"), + } + return context diff --git a/core/views/widgets/select.py b/core/views/widgets/select.py new file mode 100644 index 00000000..8bc7f5ea --- /dev/null +++ b/core/views/widgets/select.py @@ -0,0 +1,46 @@ +from django.contrib.staticfiles.storage import staticfiles_storage +from django.forms import Select, SelectMultiple + + +class AutoCompleteSelectMixin: + component_name = "autocomplete-select" + template_name = "core/widgets/autocomplete_select.jinja" + is_ajax = False + + def optgroups(self, name, value, attrs=None): + """Don't create option groups when doing ajax""" + if self.is_ajax: + return [] + return super().optgroups(name, value, attrs=attrs) + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + context["component"] = self.component_name + 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 AutoCompleteSelectUser(AutoCompleteSelectMixin, Select): + component_name = "user-ajax-select" + is_ajax = True + + +class AutoCompleteSelectMultipleUser(AutoCompleteSelectMixin, SelectMultiple): + component_name = "user-ajax-select" + is_ajax = True diff --git a/election/views.py b/election/views.py index 4280680a..1f189f41 100644 --- a/election/views.py +++ b/election/views.py @@ -11,12 +11,12 @@ from django.views.generic import DetailView, ListView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from core.views import CanCreateMixin, CanEditMixin, CanViewMixin -from core.views.forms import ( +from core.views.forms import SelectDateTime +from core.views.widgets.markdown import MarkdownInput +from core.views.widgets.select import ( AutoCompleteSelect, AutoCompleteSelectMultiple, AutoCompleteSelectUser, - MarkdownInput, - SelectDateTime, ) from election.models import Candidature, Election, ElectionList, Role, Vote diff --git a/forum/views.py b/forum/views.py index 052eb068..11f5ac59 100644 --- a/forum/views.py +++ b/forum/views.py @@ -50,7 +50,7 @@ from core.views import ( CanViewMixin, can_view, ) -from core.views.forms import MarkdownInput +from core.views.widgets.markdown import MarkdownInput from forum.models import Forum, ForumMessage, ForumMessageMeta, ForumTopic diff --git a/pedagogy/forms.py b/pedagogy/forms.py index 56a3dce7..9a182f92 100644 --- a/pedagogy/forms.py +++ b/pedagogy/forms.py @@ -25,7 +25,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from core.models import User -from core.views.forms import MarkdownInput +from core.views.widgets.markdown import MarkdownInput from pedagogy.models import UV, UVComment, UVCommentReport From 0af3505c2ad8664cdc0db7e47c7f87a9e3b29252 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 20 Oct 2024 02:26:32 +0200 Subject: [PATCH 10/33] Make a generic AjaxSelect abstract class --- .../core/components/ajax-select-index.ts | 90 ++++++++++++------- core/static/webpack/utils/api.ts | 4 +- sas/static/webpack/sas/viewer-index.ts | 4 +- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/core/static/webpack/core/components/ajax-select-index.ts b/core/static/webpack/core/components/ajax-select-index.ts index 1db8f8b8..32888cf2 100644 --- a/core/static/webpack/core/components/ajax-select-index.ts +++ b/core/static/webpack/core/components/ajax-select-index.ts @@ -112,42 +112,72 @@ class AutocompleteSelect extends inheritHtmlElement("select") { } } -@registerComponent("user-ajax-select") -export class UserAjaxSelect extends AutocompleteSelect { - public filter?: (items: T[]) => T[]; - +abstract class AjaxSelect extends AutocompleteSelect { + protected filter?: (items: TomOption[]) => TomOption[] = null; protected minCharNumberForSearch = 2; + protected abstract valueField: string; + protected abstract labelField: 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; + + public setFilter(filter?: (items: TomOption[]) => TomOption[]) { + this.filter = filter; + } + + protected getLoadFunction() { + // this will be replaced by TomSelect if we don't wrap it that way + return async (query: string, callback: TomLoadCallback) => { + 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: "id", - labelField: "display_name", + valueField: this.valueField, + labelField: this.labelField, searchField: [], // Disable local search filter and rely on tested backend - load: (query: string, callback: TomLoadCallback) => { - userSearchUsers({ - query: { - search: query, - }, - }).then((response) => { - if (response.data) { - if (this.filter) { - callback(this.filter(response.data.results), []); - } else { - callback(response.data.results, []); - } - return; - } - callback([], []); - }); - }, + load: this.getLoadFunction(), render: { ...super.tomSelectSettings().render, - option: (item: UserProfileSchema, sanitize: typeof escape_html) => { - return `
+ option: this.renderOption, + item: this.renderItem, + }, + }; + } +} + +@registerComponent("user-ajax-select") +export class UserAjaxSelect extends AjaxSelect { + protected valueField = "id"; + protected labelField = "display_name"; + + protected async search(query: string): Promise { + const resp = await userSearchUsers({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + protected tomSelectSettings(): RecursivePartial { + return super.tomSelectSettings(); + } + + protected renderOption(item: UserProfileSchema, sanitize: typeof escape_html) { + return `
${sanitize(item.display_name)} ${sanitize(item.display_name)}
`; - }, - item: (item: UserProfileSchema, sanitize: typeof escape_html) => { - return `${sanitize(item.display_name)}`; - }, - }, - }; + } + + protected renderItem(item: UserProfileSchema, sanitize: typeof escape_html) { + return `${sanitize(item.display_name)}`; } } diff --git a/core/static/webpack/utils/api.ts b/core/static/webpack/utils/api.ts index 72df568b..ac647cd7 100644 --- a/core/static/webpack/utils/api.ts +++ b/core/static/webpack/utils/api.ts @@ -1,14 +1,14 @@ import type { Client, Options, RequestResult } from "@hey-api/client-fetch"; import { client } from "#openapi"; -interface PaginatedResponse { +export interface PaginatedResponse { count: number; next: string | null; previous: string | null; results: T[]; } -interface PaginatedRequest { +export interface PaginatedRequest { query?: { page?: number; // biome-ignore lint/style/useNamingConvention: api is in snake_case diff --git a/sas/static/webpack/sas/viewer-index.ts b/sas/static/webpack/sas/viewer-index.ts index b084810c..faa9505a 100644 --- a/sas/static/webpack/sas/viewer-index.ts +++ b/sas/static/webpack/sas/viewer-index.ts @@ -177,7 +177,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => { } as PicturesFetchPicturesData) ).map(PictureWithIdentifications.fromPicture); this.selector = this.$refs.search; - this.selector.filter = (users: UserProfileSchema[]) => { + this.selector.setFilter((users: UserProfileSchema[]) => { const resp: UserProfileSchema[] = []; const ids = [ ...(this.currentPicture.identifications || []).map( @@ -190,7 +190,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => { } } return resp; - }; + }); this.currentPicture = this.pictures.find( (i: PictureSchema) => i.id === config.firstPictureId, ); From 8bbebfdb13be9e177959846cb0c1dfa68e09cc2a Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 20 Oct 2024 13:33:44 +0200 Subject: [PATCH 11/33] Add AutoCompleteSelectGroup --- .../core/components/ajax-select-index.ts | 57 +++++++++++++++---- .../core/widgets/autocomplete_select.jinja | 1 + core/views/widgets/select.py | 43 +++++++++++++- election/views.py | 10 ++-- 4 files changed, 91 insertions(+), 20 deletions(-) diff --git a/core/static/webpack/core/components/ajax-select-index.ts b/core/static/webpack/core/components/ajax-select-index.ts index 32888cf2..42875544 100644 --- a/core/static/webpack/core/components/ajax-select-index.ts +++ b/core/static/webpack/core/components/ajax-select-index.ts @@ -9,7 +9,12 @@ import type { TomSettings, } from "tom-select/dist/types/types"; import type { escape_html } from "tom-select/dist/types/utils"; -import { type UserProfileSchema, userSearchUsers } from "#openapi"; +import { + type GroupSchema, + type UserProfileSchema, + groupSearchGroup, + userSearchUsers, +} from "#openapi"; @registerComponent("autocomplete-select") class AutocompleteSelect extends inheritHtmlElement("select") { @@ -56,14 +61,9 @@ class AutocompleteSelect extends inheritHtmlElement("select") { connectedCallback() { super.connectedCallback(); - // Collect all options nodes and put them into the select node - 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) { + for (const option of Array.from(this.children).filter( + (child) => child.tagName.toLowerCase() === "option", + )) { this.removeChild(option); this.node.appendChild(option); } @@ -158,6 +158,18 @@ abstract class AjaxSelect extends AutocompleteSelect { }, }; } + + protected attachBehaviors() { + super.attachBehaviors(); + + // Gather selected options, they must be added with slots like `json` + for (const value of Array.from(this.children) + .filter((child) => child.tagName.toLowerCase() === "slot") + .map((slot) => JSON.parse(slot.innerHTML))) { + this.widget.addOption(value, true); + this.widget.addItem(value[this.valueField]); + } + } } @registerComponent("user-ajax-select") @@ -172,9 +184,6 @@ export class UserAjaxSelect extends AjaxSelect { } return []; } - protected tomSelectSettings(): RecursivePartial { - return super.tomSelectSettings(); - } protected renderOption(item: UserProfileSchema, sanitize: typeof escape_html) { return `
@@ -191,3 +200,27 @@ export class UserAjaxSelect extends AjaxSelect { return `${sanitize(item.display_name)}`; } } + +@registerComponent("group-ajax-select") +export class GroupsAjaxSelect extends AjaxSelect { + protected valueField = "id"; + protected labelField = "name"; + + protected async search(query: string): Promise { + const resp = await groupSearchGroup({ query: { search: query } }); + if (resp.data) { + return resp.data.results; + } + return []; + } + + protected renderOption(item: GroupSchema, sanitize: typeof escape_html) { + return `
+ ${sanitize(item.name)} +
`; + } + + protected renderItem(item: GroupSchema, sanitize: typeof escape_html) { + return `${sanitize(item.name)}`; + } +} diff --git a/core/templates/core/widgets/autocomplete_select.jinja b/core/templates/core/widgets/autocomplete_select.jinja index 30bc7eb5..ab91d766 100644 --- a/core/templates/core/widgets/autocomplete_select.jinja +++ b/core/templates/core/widgets/autocomplete_select.jinja @@ -7,4 +7,5 @@ {% endif %}{% for widget in group_choices %} {% include widget.template_name %}{% endfor %}{% if group_name %} {% endif %}{% endfor %} +{% for sel in selected %}{{ sel }}{% endfor %} \ No newline at end of file diff --git a/core/views/widgets/select.py b/core/views/widgets/select.py index 8bc7f5ea..a83ead7e 100644 --- a/core/views/widgets/select.py +++ b/core/views/widgets/select.py @@ -1,11 +1,27 @@ from django.contrib.staticfiles.storage import staticfiles_storage +from django.db.models import Model from django.forms import Select, SelectMultiple +from ninja import ModelSchema + +from core.models import Group, User +from core.schemas import GroupSchema, UserProfileSchema class AutoCompleteSelectMixin: component_name = "autocomplete-select" template_name = "core/widgets/autocomplete_select.jinja" - is_ajax = False + model: Model | None = None + schema: ModelSchema | None = None + pk = "id" + + def __init__(self, attrs=None, choices=()): + if self.is_ajax: + choices = () # Avoid computing anything when in ajax mode + super().__init__(attrs=attrs, choices=choices) + + @property + def is_ajax(self): + return self.model and self.schema def optgroups(self, name, value, attrs=None): """Don't create option groups when doing ajax""" @@ -27,6 +43,13 @@ class AutoCompleteSelectMixin: staticfiles_storage.url("core/components/ajax-select.scss"), ], } + if self.is_ajax: + context["selected"] = [ + self.schema.from_orm(obj).json() + for obj in self.model.objects.filter( + **{f"{self.pk}__in": context["widget"]["value"]} + ).all() + ] return context @@ -38,9 +61,23 @@ class AutoCompleteSelectMultiple(AutoCompleteSelectMixin, SelectMultiple): ... class AutoCompleteSelectUser(AutoCompleteSelectMixin, Select): component_name = "user-ajax-select" - is_ajax = True + model = User + schema = UserProfileSchema class AutoCompleteSelectMultipleUser(AutoCompleteSelectMixin, SelectMultiple): component_name = "user-ajax-select" - is_ajax = True + model = User + schema = UserProfileSchema + + +class AutoCompleteSelectGroup(AutoCompleteSelectMixin, Select): + component_name = "group-ajax-select" + model = Group + schema = GroupSchema + + +class AutoCompleteSelectMultipleGroup(AutoCompleteSelectMixin, SelectMultiple): + component_name = "group-ajax-select" + model = Group + schema = GroupSchema diff --git a/election/views.py b/election/views.py index 1f189f41..422205fd 100644 --- a/election/views.py +++ b/election/views.py @@ -15,7 +15,7 @@ from core.views.forms import SelectDateTime from core.views.widgets.markdown import MarkdownInput from core.views.widgets.select import ( AutoCompleteSelect, - AutoCompleteSelectMultiple, + AutoCompleteSelectMultipleGroup, AutoCompleteSelectUser, ) from election.models import Candidature, Election, ElectionList, Role, Vote @@ -157,10 +157,10 @@ class ElectionForm(forms.ModelForm): "candidature_groups", ] widgets = { - "edit_groups": AutoCompleteSelectMultiple, - "view_groups": AutoCompleteSelectMultiple, - "vote_groups": AutoCompleteSelectMultiple, - "candidature_groups": AutoCompleteSelectMultiple, + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + "vote_groups": AutoCompleteSelectMultipleGroup, + "candidature_groups": AutoCompleteSelectMultipleGroup, } start_date = forms.DateTimeField( From bb3f277ba5d22171a432aec0d02249cf59563db6 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 20 Oct 2024 13:40:59 +0200 Subject: [PATCH 12/33] Extract js and css from select widgets to editable class attributes --- .../core/widgets/autocomplete_select.jinja | 6 ++++-- core/views/widgets/select.py | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/core/templates/core/widgets/autocomplete_select.jinja b/core/templates/core/widgets/autocomplete_select.jinja index ab91d766..a051c202 100644 --- a/core/templates/core/widgets/autocomplete_select.jinja +++ b/core/templates/core/widgets/autocomplete_select.jinja @@ -1,5 +1,7 @@ - -{% for css in statics.csss %} +{% for js in statics.js %} + +{% endfor %} +{% for css in statics.css %} {% endfor %} diff --git a/core/views/widgets/select.py b/core/views/widgets/select.py index a83ead7e..547e7f64 100644 --- a/core/views/widgets/select.py +++ b/core/views/widgets/select.py @@ -14,6 +14,14 @@ class AutoCompleteSelectMixin: schema: ModelSchema | None = None pk = "id" + js = [ + "webpack/core/components/ajax-select-index.ts", + ] + css = [ + "webpack/core/components/ajax-select-index.css", + "core/components/ajax-select.scss", + ] + def __init__(self, attrs=None, choices=()): if self.is_ajax: choices = () # Avoid computing anything when in ajax mode @@ -33,15 +41,8 @@ class AutoCompleteSelectMixin: context = super().get_context(name, value, attrs) context["component"] = self.component_name 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"), - ], + "js": [staticfiles_storage.url(file) for file in self.js], + "css": [staticfiles_storage.url(file) for file in self.css], } if self.is_ajax: context["selected"] = [ From be5ce414ba98bbb928c1d655cb53c5d1fb8e3fa2 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 20 Oct 2024 16:57:38 +0200 Subject: [PATCH 13/33] Add proper delete button and fix item ordering --- core/static/core/components/ajax-select.scss | 5 +- .../core/components/ajax-select-index.ts | 76 ++++++++++++------- 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/core/static/core/components/ajax-select.scss b/core/static/core/components/ajax-select.scss index 8005b52d..4d0d1d65 100644 --- a/core/static/core/components/ajax-select.scss +++ b/core/static/core/components/ajax-select.scss @@ -25,10 +25,6 @@ .ts-control { .item { - .fa-times { - margin-left: 5px; - margin-right: 5px; - } cursor: pointer; background-color: #e4e4e4; @@ -39,6 +35,7 @@ margin-top: 5px; margin-bottom: 5px; padding-right: 10px; + padding-left: 10px; } } \ No newline at end of file diff --git a/core/static/webpack/core/components/ajax-select-index.ts b/core/static/webpack/core/components/ajax-select-index.ts index 42875544..2bbb6763 100644 --- a/core/static/webpack/core/components/ajax-select-index.ts +++ b/core/static/webpack/core/components/ajax-select-index.ts @@ -3,7 +3,6 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-component import TomSelect from "tom-select"; import type { RecursivePartial, - TomItem, TomLoadCallback, TomOption, TomSettings, @@ -71,14 +70,25 @@ class AutocompleteSelect extends inheritHtmlElement("select") { this.attachBehaviors(); } + protected shouldLoad(query: string) { + console.log(this); + 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) => { - return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than 2 characters - }, + 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 `
@@ -86,7 +96,7 @@ class AutocompleteSelect extends inheritHtmlElement("select") {
`; }, item: (item: TomOption, sanitize: typeof escape_html) => { - return `${sanitize(item.text)}`; + return `${sanitize(item.text)}`; }, // biome-ignore lint/style/useNamingConvention: that's how it's defined not_loading: (data: TomOption, _sanitize: typeof escape_html) => { @@ -101,14 +111,7 @@ class AutocompleteSelect extends inheritHtmlElement("select") { } protected attachBehaviors() { - // Allow removing selected items by clicking on them - this.widget.on("item_select", (item: TomItem) => { - this.widget.removeItem(item); - }); - // Remove typed text once an item has been selected - this.widget.on("item_add", () => { - this.widget.setTextboxValue(""); - }); + /* Called once the widget has been initialized */ } } @@ -118,6 +121,8 @@ abstract class AjaxSelect extends AutocompleteSelect { protected abstract valueField: string; protected abstract labelField: string; + protected abstract searchField: string[]; + protected abstract renderOption( item: TomOption, sanitize: typeof escape_html, @@ -129,16 +134,28 @@ abstract class AjaxSelect extends AutocompleteSelect { this.filter = filter; } - protected getLoadFunction() { - // this will be replaced by TomSelect if we don't wrap it that way - return async (query: string, callback: TomLoadCallback) => { - const resp = await this.search(query); - if (this.filter) { - callback(this.filter(resp), []); - } else { - callback(resp, []); - } - }; + 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 { @@ -149,8 +166,9 @@ abstract class AjaxSelect extends AutocompleteSelect { duplicates: false, valueField: this.valueField, labelField: this.labelField, - searchField: [], // Disable local search filter and rely on tested backend - load: this.getLoadFunction(), + 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, @@ -166,7 +184,7 @@ abstract class AjaxSelect extends AutocompleteSelect { for (const value of Array.from(this.children) .filter((child) => child.tagName.toLowerCase() === "slot") .map((slot) => JSON.parse(slot.innerHTML))) { - this.widget.addOption(value, true); + this.widget.addOption(value, false); this.widget.addItem(value[this.valueField]); } } @@ -176,6 +194,7 @@ abstract class AjaxSelect extends AutocompleteSelect { export class UserAjaxSelect extends AjaxSelect { protected valueField = "id"; protected labelField = "display_name"; + protected searchField: string[] = []; // Disable local search filter and rely on tested backend protected async search(query: string): Promise { const resp = await userSearchUsers({ query: { search: query } }); @@ -197,7 +216,7 @@ export class UserAjaxSelect extends AjaxSelect { } protected renderItem(item: UserProfileSchema, sanitize: typeof escape_html) { - return `${sanitize(item.display_name)}`; + return `${sanitize(item.display_name)}`; } } @@ -205,6 +224,7 @@ export class UserAjaxSelect extends AjaxSelect { export class GroupsAjaxSelect extends AjaxSelect { protected valueField = "id"; protected labelField = "name"; + protected searchField = ["name"]; protected async search(query: string): Promise { const resp = await groupSearchGroup({ query: { search: query } }); @@ -221,6 +241,6 @@ export class GroupsAjaxSelect extends AjaxSelect { } protected renderItem(item: GroupSchema, sanitize: typeof escape_html) { - return `${sanitize(item.name)}`; + return `${sanitize(item.name)}`; } } From 45441c351df6ecb267487ece80938b814781b32b Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 20 Oct 2024 17:37:51 +0200 Subject: [PATCH 14/33] Improve ajax-select style --- core/static/core/components/ajax-select.scss | 38 +++++++++---------- .../core/components/ajax-select-index.ts | 3 +- core/views/widgets/select.py | 1 + 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/core/static/core/components/ajax-select.scss b/core/static/core/components/ajax-select.scss index 4d0d1d65..abecc08b 100644 --- a/core/static/core/components/ajax-select.scss +++ b/core/static/core/components/ajax-select.scss @@ -1,10 +1,4 @@ /* This also requires ajax-select-index.css */ -.tomselected { - .select2-container--default { - color: black; - } -} - .ts-dropdown { .select-item { @@ -22,20 +16,22 @@ } } -.ts-control { - - .item { - - cursor: pointer; - background-color: #e4e4e4; - border: 1px solid #aaa; - border-radius: 4px; - display: inline-block; - margin-left: 5px; - margin-top: 5px; - margin-bottom: 5px; - padding-right: 10px; - padding-left: 10px; - } +.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove { + border-left: 1px solid black; +} +.ts-wrapper.multi .ts-control [data-value] { + background-image: none; + cursor: pointer; + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + display: inline-block; + margin-left: 5px; + margin-top: 5px; + margin-bottom: 5px; + padding-right: 10px; + padding-left: 10px; + text-shadow: none; + box-shadow: none; } \ No newline at end of file diff --git a/core/static/webpack/core/components/ajax-select-index.ts b/core/static/webpack/core/components/ajax-select-index.ts index 2bbb6763..3dedce14 100644 --- a/core/static/webpack/core/components/ajax-select-index.ts +++ b/core/static/webpack/core/components/ajax-select-index.ts @@ -1,4 +1,4 @@ -import "tom-select/dist/css/tom-select.css"; +import "tom-select/dist/css/tom-select.default.css"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import TomSelect from "tom-select"; import type { @@ -71,7 +71,6 @@ class AutocompleteSelect extends inheritHtmlElement("select") { } protected shouldLoad(query: string) { - console.log(this); return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than setup number of characters } diff --git a/core/views/widgets/select.py b/core/views/widgets/select.py index 547e7f64..abd9732d 100644 --- a/core/views/widgets/select.py +++ b/core/views/widgets/select.py @@ -39,6 +39,7 @@ class AutoCompleteSelectMixin: def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) + context["widget"]["attrs"]["autocomplete"] = "off" context["component"] = self.component_name context["statics"] = { "js": [staticfiles_storage.url(file) for file in self.js], From 301fc736879a4af312cd30cc2fab4ab8851a41f8 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 20 Oct 2024 18:13:48 +0200 Subject: [PATCH 15/33] Fix markdown input initial value and crash when alpine is not loaded --- .../webpack/core/components/easymde-index.ts | 28 +++++++++++++------ .../core/widgets/markdown_textarea.jinja | 4 ++- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/core/static/webpack/core/components/easymde-index.ts b/core/static/webpack/core/components/easymde-index.ts index 734aa9ec..eba719d9 100644 --- a/core/static/webpack/core/components/easymde-index.ts +++ b/core/static/webpack/core/components/easymde-index.ts @@ -13,16 +13,22 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => { element: textarea, spellChecker: false, autoDownloadFontAwesome: false, - previewRender: Alpine.debounce((plainText: string, preview: MarkdownInput) => { - const func = async (plainText: string, preview: MarkdownInput): Promise => { - preview.innerHTML = ( - await markdownRenderMarkdown({ body: { text: plainText } }) - ).data as string; + previewRender: (plainText: string, preview: MarkdownInput) => { + /* This is wrapped this way to allow time for Alpine to be loaded on the page */ + return Alpine.debounce((plainText: string, preview: MarkdownInput) => { + const func = async ( + plainText: string, + preview: MarkdownInput, + ): Promise => { + preview.innerHTML = ( + await markdownRenderMarkdown({ body: { text: plainText } }) + ).data as string; + return null; + }; + func(plainText, preview); return null; - }; - func(plainText, preview); - return null; - }, 300), + }, 300)(plainText, preview); + }, forceSync: true, // Avoid validation error on generic create view toolbar: [ { @@ -187,6 +193,10 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => { class MarkdownInput extends inheritHtmlElement("textarea") { connectedCallback() { super.connectedCallback(); + const initialValue = this.querySelector("slot[name='initial']"); + if (initialValue as HTMLSlotElement) { + this.node.textContent = initialValue.textContent; + } loadEasyMde(this.node); } } diff --git a/core/templates/core/widgets/markdown_textarea.jinja b/core/templates/core/widgets/markdown_textarea.jinja index 1131d5bd..1531ed3f 100644 --- a/core/templates/core/widgets/markdown_textarea.jinja +++ b/core/templates/core/widgets/markdown_textarea.jinja @@ -2,6 +2,8 @@ - {% if widget.value %}{{ widget.value }}{% endif %} + + {% if widget.value %}{{ widget.value }}{% endif %} +
From 517263dd581acf02f81f1ae78ad8d998e139ddc6 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 20 Oct 2024 18:29:48 +0200 Subject: [PATCH 16/33] Automatically move inner html in created node when inheriting from HTMLElement --- .../core/components/ajax-select-index.ts | 20 ++++++++++--------- .../webpack/core/components/easymde-index.ts | 4 ---- core/static/webpack/utils/web-components.ts | 6 +++++- .../core/widgets/markdown_textarea.jinja | 4 +--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/core/static/webpack/core/components/ajax-select-index.ts b/core/static/webpack/core/components/ajax-select-index.ts index 3dedce14..4ba1e091 100644 --- a/core/static/webpack/core/components/ajax-select-index.ts +++ b/core/static/webpack/core/components/ajax-select-index.ts @@ -60,12 +60,6 @@ class AutocompleteSelect extends inheritHtmlElement("select") { connectedCallback() { super.connectedCallback(); - for (const option of Array.from(this.children).filter( - (child) => child.tagName.toLowerCase() === "option", - )) { - this.removeChild(option); - this.node.appendChild(option); - } this.widget = new TomSelect(this.node, this.tomSelectSettings()); this.attachBehaviors(); } @@ -129,6 +123,7 @@ abstract class AjaxSelect extends AutocompleteSelect { 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; } @@ -176,13 +171,20 @@ abstract class AjaxSelect extends AutocompleteSelect { }; } + 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 Array.from(this.children) - .filter((child) => child.tagName.toLowerCase() === "slot") - .map((slot) => JSON.parse(slot.innerHTML))) { + 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/easymde-index.ts b/core/static/webpack/core/components/easymde-index.ts index eba719d9..d99799a0 100644 --- a/core/static/webpack/core/components/easymde-index.ts +++ b/core/static/webpack/core/components/easymde-index.ts @@ -193,10 +193,6 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => { class MarkdownInput extends inheritHtmlElement("textarea") { connectedCallback() { super.connectedCallback(); - const initialValue = this.querySelector("slot[name='initial']"); - if (initialValue as HTMLSlotElement) { - this.node.textContent = initialValue.textContent; - } loadEasyMde(this.node); } } diff --git a/core/static/webpack/utils/web-components.ts b/core/static/webpack/utils/web-components.ts index f6949731..8bec98f9 100644 --- a/core/static/webpack/utils/web-components.ts +++ b/core/static/webpack/utils/web-components.ts @@ -43,7 +43,11 @@ export function inheritHtmlElement(tagNam this.removeAttributeNode(attr); this.node.setAttributeNode(attr); } - // Atuomatically add node to DOM if autoAddNode is true or unspecified + + this.node.innerHTML = this.innerHTML; + this.innerHTML = ""; + + // Automatically add node to DOM if autoAddNode is true or unspecified if (autoAddNode === undefined || autoAddNode) { this.appendChild(this.node); } diff --git a/core/templates/core/widgets/markdown_textarea.jinja b/core/templates/core/widgets/markdown_textarea.jinja index 1531ed3f..1131d5bd 100644 --- a/core/templates/core/widgets/markdown_textarea.jinja +++ b/core/templates/core/widgets/markdown_textarea.jinja @@ -2,8 +2,6 @@ - - {% if widget.value %}{{ widget.value }}{% endif %} - + {% if widget.value %}{{ widget.value }}{% endif %}
From 125157fdf4f1a0b217d1848de755fe6c414c3533 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 20 Oct 2024 18:35:55 +0200 Subject: [PATCH 17/33] Move gettext to the top --- core/templates/core/base.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja index fbd4c997..8c411cc5 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -21,6 +21,7 @@ + @@ -303,7 +304,6 @@ {% block script %} -