mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-10 03:49:24 +00:00
45
core/static/core/components/ajax-select.scss
Normal file
45
core/static/core/components/ajax-select.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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("");
|
||||
});
|
||||
}
|
||||
}
|
183
core/static/webpack/core/components/ajax-select-base.ts
Normal file
183
core/static/webpack/core/components/ajax-select-base.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
100
core/static/webpack/core/components/ajax-select-index.ts
Normal file
100
core/static/webpack/core/components/ajax-select-index.ts
Normal 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>`;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
33
core/static/webpack/core/components/include-index.ts
Normal file
33
core/static/webpack/core/components/include-index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
Reference in New Issue
Block a user