import "@supersoniks/concorde/core/components/ui/button/button"; import { SonicToast } from "@supersoniks/concorde/core/components/ui/toast/toast"; import { SubscriberInterface } from "@supersoniks/concorde/core/mixins/Subscriber"; import API from "@supersoniks/concorde/core/utils/api"; import Objects from "@supersoniks/concorde/core/utils/Objects"; import { PublisherManager } from "@supersoniks/concorde/utils"; import { property } from "lit/decorators.js"; import { PublisherContentType } from "../_types/types"; import { MixinArgsType } from "../_types/types"; import { ResultTypeInterface } from "@supersoniks/concorde/core/utils/api"; import { detecHTMLLanguageChange } from "../utils/HTML"; type Constructor = new (...args: MixinArgsType[]) => T; const fetchersInError: Set<{ _fetchData: () => void }> = new Set(); export const invalidateFetchersInError = () => { const fetchersInErrorCopy = new Set(fetchersInError); fetchersInError.clear(); for (const fetcher of fetchersInErrorCopy) { fetcher._fetchData(); } }; const errorsListeners = new Set<(apiResponse: Response) => void>(); export const onFetchError = ( errorListener: (apiResponse: Response) => void ) => { errorsListeners.add(errorListener); }; export const offFetchError = ( errorListener: (apiResponse: Response) => void ) => { errorsListeners.delete(errorListener); }; const dispatchFetchError = (apiResponse: Response) => { for (const listener of errorsListeners) { listener(apiResponse); } }; const fetchComponents = new Set<{ _fetchData: () => void }>(); let languageChangeRequestId = 0; detecHTMLLanguageChange(async () => { //relaunch 4 fetchs then wait the result before launching the next 4 fetchs languageChangeRequestId++; const currentRequestId = languageChangeRequestId; const fetchers = Array.from(fetchComponents); while (fetchers.length > 0) { if (currentRequestId != languageChangeRequestId) return; const fourFetchers = fetchers.splice(0, 4); await Promise.all(fourFetchers.map((fetcher) => fetcher._fetchData())); } }); const Fetcher = < T extends Constructor>, PropsType extends PublisherContentType = PublisherContentType >( superClass: T, propsType?: PropsType ) => { propsType; class FetcherElement extends superClass { api: API | null = null; /** * Après le chargement des données on traverse l'objet reçu en fonctione de la cible exprimées dans cette propriété avec la dot syntaxe. * C'est cette donnée cible qui est injectée dans les pros et donc disponible via le publisher disponible globalement via PublisherManager.get(dataProvider) */ key = ""; /** * isFirstLoad vaut true jusqu'au premier chargement de données */ isFirstLoad = true; /** * isLoading vaut true pendant le chargement des données */ isLoading = false; lazyLoad?: boolean; /** * IObserver est l'intersection observer qui permet de charger les données au scroll si l'attribut lazyload est renseigné */ iObserver: IntersectionObserver | null = null; /** * On peut désactiver le fetch programmatiquement via cette propriété. * Cela est le cas pour le composant sonic-list qui ne fetch que si l'attribut fetch est renseigné */ isFetchEnabled = true; /** * Result of the fetch */ fetchedData: any = null; constructor(...args: MixinArgsType[]) { super(); args; this.dataProvider = ""; } _endPoint = ""; @property() noErrorsRecordings = false; @property() get props(): (PropsType & ResultTypeInterface) | null { return super.props as (PropsType & ResultTypeInterface) | null; } set props(value) { super.props = value; } @property({ type: String }) set endPoint(value: string) { this._endPoint = value; if (this.isConnected) this._fetchData(); } get endPoint(): string { return this._endPoint; } @property() requestId = 0; @property({ type: Number }) refetchEveryMs = 0; refetchTimeOutId?: ReturnType; /** * _isFetching est une propriété sensée privée qui permet de savoir si un fetch est en cours * Elle ne peut pas etre veritablement privée actuellement en raison d'une limitation contextuelle a traiter */ _isFetching = false; /** * _mustRefetch est une propriété sensée privée qui permet de savoir si un fetch est en cours * Elle ne peut pas etre veritablement privée actuellement en raison d'une limitation contextuelle a traiter */ _mustRefetch = false; handleStartFetching(): "fetching" | "okToFetch" { if (this._isFetching) { this._mustRefetch = true; return "fetching"; } this._isFetching = true; return "okToFetch"; } handleEndFetching() { this._isFetching = false; if (this._mustRefetch) { this._mustRefetch = false; this._fetchData(); } } /** * * C'est ici que les données sont chargées via l'utilitaire API * Elles sont ensuite injectées dans le publisher en accord avec la cible définie dans la propriété key * Un Toast est affiché si le chargement échoue */ async _fetchData() { this.requestUpdate(); if (!this.isFetchEnabled) return; this.api = new API(this.getApiConfiguration()); if (!this.api) return; // if (!this.dataProvider) return; this.dispatchEvent(new CustomEvent("loading", { detail: this })); if (this.getAttribute("localStorage") === "enabled") { await PublisherManager.getInstance().isLocalStrorageReady; } if (!this.isConnected) return; const headerData = PublisherManager.getInstance() .get(this.getAncestorAttributeValue("headersDataProvider")) .get(); this.isLoading = true; if ( Objects.isObject(this.props) && Object.keys(this.props || {}).length > 0 && this.isFirstLoad ) { window.requestAnimationFrame(() => { this.dispatchEvent(new CustomEvent("load", { detail: this })); this.isFirstLoad = false; this.isLoading = false; }); } const fetchStatus = this.handleStartFetching(); if (fetchStatus === "fetching") { return; } let data = null; try { data = await this.api.get( this.endPoint || this.dataProvider || "", headerData ); } catch (_error) {} this.handleEndFetching(); this.fetchedData = data; if (this.api.lastResult && !this.api.lastResult.ok) { if (!this.noErrorsRecordings) { fetchersInError.add(this); } dispatchFetchError(this.api.lastResult); } if (!this.isConnected) { return; } if (!data) { // Cela n'arrive normalement que lorsque l'on quitte une page pendant un fetch on n'affiche donc pas de message this.isLoading = false; if (this.refetchEveryMs && this.isConnected) { this.refetchTimeOutId = setTimeout( () => this._fetchData(), this.refetchEveryMs ); } return; } else if ( data._sonic_http_response_ && !data._sonic_http_response_.ok && Object.keys(data).length === 1 ) { // Si data ne contient que la réponse HTTP, avec un statut not ok, on affiche un message SonicToast.add({ text: "Network Error", status: "error" }); } if (this.key) { const response = data._sonic_http_response_; /* preserveOtherKeys s'exprime lorsque le paramètre "key" est défini * Conserve les autres propriétés de l'objet reçu, en plus des propriétés définies sous "key" */ const path = this.key.split("."); data = Objects.traverse( data, path, this.hasAttribute("preserveOtherKeys") ); if (data && Objects.isObject(data) && response) data._sonic_http_response_ = response; } this.props = data; this.dispatchEvent(new CustomEvent("load", { detail: this })); this.isFirstLoad = false; this.isLoading = false; if (this.refetchEveryMs && this.isConnected) { this.refetchTimeOutId = setTimeout( () => this._fetchData(), this.refetchEveryMs ); } } onInvalidate?: () => void; disconnectedCallback(): void { super.disconnectedCallback(); fetchComponents.delete(this); this.publisher?.offInvalidate(this.onInvalidate); clearTimeout(this.refetchTimeOutId); this.isFirstLoad = false; } connectedCallback() { // this.noShadowDom = ""; this.lazyLoad = this.lazyLoad !== undefined ? this.lazyLoad : this.hasAttribute("lazyload"); fetchComponents.add(this); super.connectedCallback(); if (!this.isFetchEnabled) { return; } this.key = this.key != "" ? this.key : this.getAttribute("key"); if (this.props) { this.publisher.set(this.props); } this.onInvalidate = () => this._fetchData(); this.publisher?.onInvalidate(this.onInvalidate); if (!this.lazyLoad) { this._fetchData(); } else { this.handleLazyLoad(); } } /** * Première update, le comportement de lazyload est géré ici a l'aide d'un intersection observer. */ lazyLoadPlaceHolder?: HTMLSpanElement; handleLazyLoad() { if (!this.lazyLoad) { return; } /** * Should not be required * Todo : remove after success * creates forced reflow */ // const rect = this.getBoundingClientRect(); // if ( // rect.x < window.innerWidth && // rect.right > 0 && // rect.y < window.innerHeight && // rect.right > 0 // ) { // this._fetchData(); // return; // } const boundsRatio = parseFloat( this.getAttribute("lazyBoundsRatio") || "1" ); const options: IntersectionObserverInit = { root: null, rootMargin: Math.max( window.innerWidth * boundsRatio, window.innerHeight * boundsRatio ) + "px", threshold: 0.9, }; this.iObserver = new IntersectionObserver( (entries) => this.onIntersection(entries), options ); const root = this.shadowRoot ? this.shadowRoot : this; let elt = [...root.children].filter( (e) => e.nodeName.toLowerCase() != "style" )[0] as HTMLElement; if (elt?.nodeName.toLocaleLowerCase() == "slot") elt = [...elt.children].filter( (e) => e.nodeName.toLowerCase() != "style" )[0] as HTMLElement; if (!elt || elt.nodeName.toLocaleLowerCase() == "template") { elt = document.createElement("div"); const style = elt.style; /** * !!! Pas de position absolute ici si on veut que le composant soit au bon endroit dans la page pour l'intersection observer * En effest sinon il vpeut remonter en congugaison avec le style display="contents" */ style.pointerEvents = "none"; style.width = "1px"; style.height = "1px"; this.lazyLoadPlaceHolder = elt; this.appendChild(elt); } if (elt) { this.iObserver.observe(elt); } else if (this.isFirstLoad) { this._fetchData(); } } onIntersection(entries: IntersectionObserverEntry[]) { for (const e of entries) { if (e.isIntersecting && this.isFirstLoad) { this._fetchData(); this.lazyLoadPlaceHolder?.remove(); this.lazyLoadPlaceHolder = undefined; this.iObserver?.disconnect(); break; } } } } return FetcherElement; //as Constructor & T; }; export default Fetcher;