import { html, LitElement, nothing, PropertyValues } from "lit"; import { customElement, property } from "lit/decorators.js"; import Subscriber from "@supersoniks/concorde/core/mixins/Subscriber"; import { map } from "lit/directives/map.js"; import DataProvider, { PublisherManager } from "@supersoniks/concorde/core/utils/PublisherProxy"; import "@supersoniks/concorde/core/components/functional/list/list"; import { HTML } from "@supersoniks/concorde/utils"; import { ListItems } from "@supersoniks/concorde/core/components/functional/list/list"; type QueueItem = { id: string; endPoint: string; dataProvider: string; offset: number; limit: number; }; type QueueProps = QueueItem[] & { resultCount?: number; lastFetchedData?: unknown; }; const tagName = "sonic-queue"; /** *### Une Queue charge du contenu par lot selon l'expression renseignée dans l'attribut *dataProviderExpression*. * * Chaque lot est chargé par un composant [List](./?path=/docs/core-components-functional-list-list--basic) dont le dataProvider créé à partir de l'attribut dataProviderExpression * * A l'initialisation elle regarde l'attribut dataFilterProvider qui donne l'adresse d'un publisher * Si cet attribut est touvé, Queue écoute le publisher fourni et se réinitialise à chaque modification du contenu de celui-ci. * Les valeurs renseignées dans ce publisher sont ajoutées en get à chaque requête * * la proriété *key* peut être utilisé pour cibler une propriété particulière du retour de l'api. */ @customElement(tagName) export default class Queue extends Subscriber(LitElement, {} as QueueProps) { @property({ type: Array }) templates: Array | null = null; @property({ type: Function }) items: ListItems | null = null; @property({ type: Function }) noItems: ListItems | null = null; @property({ type: Function }) skeleton: ListItems | null = null; lastRequestTime = 0; key = ""; @property({ type: Object }) itemPropertyMap: object | null = null; /** * Durée cible en ms d'une requête pour afficher 1 lot. */ @property() cache: RequestCache = "default"; @property() targetRequestDuration = 500; /* * Quantité d'éléments devant être chargés dans le premier lot. * Cette valeur est mise à jour ensuite par Queue pour chauq lot pour se rapprocher tanque possible de *targetRequestDuration* */ @property() limit = 5; @property() lazyBoundsRatio = 1; @property() offset = 0; @property() resultCount = 0; @property({ type: Boolean }) noLazyload = false; @property({ type: String }) loader = "inline"; @property() filteredFields = ""; disconnectedCallback() { for (const dataProvider of this.listDataProviders) { PublisherManager.delete(dataProvider); this.listDataProviders = []; } this.filterPublisher?.offInternalMutation(this.updateFilteredContent); // reset internal state this.props = null; this.limit = 5; this.offset = 0; this.resultCount = 0; this.searchHash = ""; this.requestId = 0; this.isFirstRequest = true; this.nextHadEvent = false; this.publisher.set({}); super.disconnectedCallback(); return; } static instanceCounter = 0; instanceId = 0; localStorage = "disabled"; async connectedCallback() { this.instanceId = Queue.instanceCounter++; this.localStorage = this.getAttribute("localStorage") || this.localStorage; this.filterTimeoutMs = parseInt( this.getAttribute("filterTimeoutMs") || "400" ); //On supprime l'attribut car une queue ne doi pas être en localstorage, ce sont ses sous composants list qui doivent l'être this.removeAttribute("localStorage"); this.noShadowDom = ""; this.defferedDebug = this.hasAttribute("debug") || null; /**Compat avec states et routing **/ if (!this.dataProvider) this.dataProvider = this.dataProviderExpression || "sonic-queue-" + this.instanceId + "-" + Math.random().toString(36).substring(7); if (!this.dataProviderExpression) { this.dataProviderExpression = HTML.getAncestorAttributeValue(this.parentElement, "dataProvider") || ""; } this.storeScrollPosition(); super.connectedCallback(); this.publisher.set({}); this.key = this.getAttribute("key"); await PublisherManager.getInstance().isLocalStrorageReady; if (!this.templates) this.templates = Array.from( this.querySelectorAll("template") ) as Array; this.lastRequestTime = new Date().getTime(); this.configFilter(); } filterPublisher: DataProvider | null = null; configFilter() { const dataFilterProvider = this.getAncestorAttributeValue("dataFilterProvider"); if (!dataFilterProvider) { this.next(); return; } this.filterPublisher = PublisherManager.getInstance().get(dataFilterProvider); this.filterPublisher?.onInternalMutation(this.updateFilteredContent); } filterTimeoutId?: ReturnType; filterTimeoutMs = 400; searchHash = ""; requestId = 0; isFirstRequest = true; updateFilteredContent = () => { /** * On ne lance la recherche que si le hash de recherche est différent */ const dataProvider = this.dataProviderExpression; const split = dataProvider.split("?"); split.shift(); const searchParams = new URLSearchParams(split.join("?")); const filterData: Record = this.filterPublisher?.get() ; const filteredFieldsArray = this.filteredFields.split(" "); for (const f in filterData) { let value = filterData[f]; if (Array.isArray(value)) value = value.filter((v: string | null) => v !== null); if ( (this.filteredFields && !filteredFieldsArray.includes(f)) || value == null || value.toString() === "" ) continue; searchParams.set(f, filterData[f].toString()); } const searchHash = searchParams.toString(); if (searchHash == this.searchHash && !this.isFirstRequest) return; this.searchHash = searchHash; /** * on reset les données avant de lancer la requète */ for (const dataProvider of this.listDataProviders) { PublisherManager.delete(dataProvider); // this.publisher.lastFetchedData = {}; } this.listDataProviders = []; clearTimeout(this.filterTimeoutId); this.filterTimeoutId = setTimeout( async () => { const count = this.resultCount; this.props = null; //On garde le décompte au cas ou il n'y aurait pas rechargement this.requestId++; this.resultCount = count; await PublisherManager.getInstance().isLocalStrorageReady; window.requestAnimationFrame(() => this.next()); }, this.isFirstRequest ? 0 : this.filterTimeoutMs ); this.isFirstRequest = false; }; storeScrollPosition() { if (!this.isSafari()) { return; } this.storedScrollX = window.scrollX; this.storedScrollY = window.scrollY; } isSafari() { return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); } protected updated(_changedProperties: PropertyValues): void { if (this.isSafari()) { if ( Math.abs(this.storedScrollX - window.scrollX) > 10 || Math.abs(this.storedScrollY - window.scrollY) > 10 ) { window.scrollTo(this.storedScrollX, this.storedScrollY); } //Hack pour éviter que le scroll ne retourne en haut si safari window.requestAnimationFrame(() => { if ( Math.abs(this.storedScrollX - window.scrollX) > 10 || Math.abs(this.storedScrollY - window.scrollY) > 10 ) { window.scrollTo(this.storedScrollX, this.storedScrollY); } }); } super.updated(_changedProperties); } /** * Cette expression est utilisée comme modèle par le composant Queue pour renseigngner le dataProvider de la [liste](./?path=/docs/core-components-functional-list-list--basic) créée. * * l'expression *$offset* est alors remplacée par le numéro de l'élément à partir duquel démarrer * * l'expression *$limit* est remplacée par la valeur représentant le nombre d'éléments à charger * * Si pas d'expression *$offset* le composant se comporte un peu comme une liste, il ne va pas essayer de charger les éléments suivants */ @property({ type: String }) dataProviderExpression = ""; @property({ type: Boolean }) invalidateOnPageShow = false; @property({ type: String }) idKey = "id"; resetDuration() { this.lastRequestTime = new Date().getTime(); } listDataProviders: string[] = []; nextHadEvent = false; next(e?: CustomEvent) { let offset = this.offset; const newTime = new Date().getTime(); const requestDuration = newTime - this.lastRequestTime; /** * Le rechargement n'est pas garanti si pas de changement dans les filtres * Un ne repasse donc à 0 qu'à partir du premier chargement. * */ if (!this.nextHadEvent && e) { this.publisher.resultCount = 0; // this.publisher.lastFetchedData = {}; this.resultCount = 0; } this.nextHadEvent = !!e; if (e) { this.publisher.lastFetchedData = e.detail.fetchedData; if (e.detail.requestId < this.requestId) return; this.resultCount += e.detail.props?.length || 0; if ( !e.detail.isFirstLoad || !e.detail.props?.length || this.dataProviderExpression.indexOf("$offset") == -1 ) { this.publisher.resultCount = this.resultCount; // this.publisher.lastFetchedData = {}; return; } } if (!Array.isArray(this.props)) { const newProps: QueueProps = []; newProps.resultCount = this.resultCount; newProps.lastFetchedData = e?.detail.fetchedData || {}; this.props = newProps; } else { const props: Array<{ offset: number; limit: number }> = this.props; const item = props[props.length - 1]; offset = parseInt(item.offset.toString()) + parseInt(item.limit.toString()); } if (requestDuration > 0 && e && !this.localStorage) { this.limit = Math.round( (this.limit / requestDuration) * this.targetRequestDuration ); } if (this.limit < 1) this.limit = 1; if (this.limit > 15) this.limit = 15; let dataProvider = this.dataProviderExpression .replace("$offset", offset + "") .replace("$limit", this.limit + ""); const split = dataProvider.split("?"); let endpoint = split.shift(); const searchParams = new URLSearchParams(split.join("?")); const filterData: Record = this.filterPublisher?.get(); const filteredFieldsArray = this.filteredFields.split(" "); for (const f in filterData) { if ( (this.filteredFields && filteredFieldsArray.includes(f)) || filterData[f] == null || filterData[f] == "" ) continue; searchParams.set(f, filterData[f]); } if (!this.searchHash) this.searchHash = searchParams.toString(); endpoint = endpoint + "?" + searchParams.toString(); dataProvider = dataProvider + "_item_from_queue_" + this.instanceId; this.listDataProviders.push(dataProvider); const newProps: QueueProps = [ ...this.props, { id: searchParams.toString() + "/" + this.props.length, dataProvider: dataProvider, endPoint: endpoint, offset: offset, limit: this.limit, }, ]; newProps.resultCount = this.resultCount; newProps.lastFetchedData = e?.detail.fetchedData || {}; this.props = newProps; this.lastRequestTime = new Date().getTime(); } storedScrollY = 0; storedScrollX = 0; render() { this.storeScrollPosition(); if (!Array.isArray(this.props)) { return nothing; } let lazyload = !this.noLazyload; if (this.props.length == 1) { lazyload = false; } this.style.display = "block"; return html` ${map(this.props, (item, index) => { const templates = index == 0 ? this.templates : this.templates?.filter( (elt) => elt.getAttribute("data-value") != "no-item" ); return html` `; })} `; } }