Merge pull request #899 from ae-utbm/ajax-select

Improve ajax select
This commit is contained in:
thomas girod
2024-11-10 13:37:57 +01:00
committed by GitHub
58 changed files with 1325 additions and 565 deletions

View File

@ -0,0 +1,45 @@
/* This also requires ajax-select-index.css */
.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-wrapper {
margin: 5px;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
border-left: 1px solid #aaa;
}
.ts-wrapper.multi .ts-control {
[data-value],
[data-value].active {
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;
}
}

View File

@ -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;

View File

@ -1,93 +0,0 @@
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 { escape_html } from "tom-select/dist/types/utils";
import { type UserProfileSchema, userSearchUsers } from "#openapi";
@registerComponent("ajax-select")
export class AjaxSelect extends inheritHtmlElement("select") {
public widget: TomSelect;
public filter?: <T>(items: T[]) => T[];
constructor() {
super();
window.addEventListener("DOMContentLoaded", () => {
this.loadTomSelect();
});
}
loadTomSelect() {
const minCharNumberForSearch = 2;
let maxItems = 1;
if (this.node.multiple) {
maxItems = Number.parseInt(this.node.dataset.max) ?? null;
}
this.widget = new TomSelect(this.node, {
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 ?? "",
shouldLoad: (query: string) => {
return query.length >= minCharNumberForSearch; // Avoid launching search with less than 2 characters
},
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([], []);
});
},
render: {
option: (item: UserProfileSchema, sanitize: typeof escape_html) => {
return `<div class="select-item">
<img
src="${sanitize(item.profile_pict)}"
alt="${sanitize(item.display_name)}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>
<span class="select-item-text">${sanitize(item.display_name)}</span>
</div>`;
},
item: (item: UserProfileSchema, sanitize: typeof escape_html) => {
return `<span><i class="fa fa-times"></i>${sanitize(item.display_name)}</span>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: 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>`;
},
},
});
// 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("");
});
}
}

View File

@ -0,0 +1,183 @@
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 */
const initial = this.querySelector("slot[name='initial']")?.textContent;
this.initialValues = initial ? JSON.parse(initial) : [];
super.connectedCallback();
}
protected attachBehaviors() {
super.attachBehaviors();
// Gather selected options, they must be added with slots like `<slot>json</slot>`
for (const value of this.initialValues) {
this.widget.addOption(value, false);
this.widget.addItem(value[this.valueField]);
}
}
}

View File

@ -0,0 +1,100 @@
import "tom-select/dist/css/tom-select.default.css";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type GroupSchema,
type SithFileSchema,
type UserProfileSchema,
groupSearchGroup,
sithfileSearchFiles,
userSearchUsers,
} from "#openapi";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
@registerComponent("autocomplete-select")
export class AutoCompleteSelect extends AutoCompleteSelectBase {}
@registerComponent("user-ajax-select")
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<TomOption[]> {
const resp = await userSearchUsers({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: UserProfileSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<img
src="${sanitize(item.profile_pict)}"
alt="${sanitize(item.display_name)}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>
<span class="select-item-text">${sanitize(item.display_name)}</span>
</div>`;
}
protected renderItem(item: UserProfileSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.display_name)}</span>`;
}
}
@registerComponent("group-ajax-select")
export class GroupsAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await groupSearchGroup({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: GroupSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: GroupSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@registerComponent("sith-file-ajax-select")
export class SithFileAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "path";
protected searchField = ["path", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await sithfileSearchFiles({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: SithFileSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.path)}</span>
</div>`;
}
protected renderItem(item: SithFileSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.path)}</span>`;
}
}

View File

@ -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<null> => {
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<null> => {
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: [
{
@ -185,8 +191,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);
}
}

View File

@ -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);
}
}
}

View File

@ -1,14 +1,14 @@
import type { Client, Options, RequestResult } from "@hey-api/client-fetch";
import { client } from "#openapi";
interface PaginatedResponse<T> {
export interface PaginatedResponse<T> {
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

View File

@ -30,8 +30,7 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(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,14 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
this.removeAttributeNode(attr);
this.node.setAttributeNode(attr);
}
this.appendChild(this.node);
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);
}
}
};
}