Source: core/dynamic_frame.js

import { Controller } from "../controller.js";
import { parseBoolean, parseDuration } from "../util.js";

/*
This is the base controller for DynamicFrames
Extend this and override params() and optionally replaceContent()

The root HTML node must have a `:url` attribute - this can be relative or absolute
To pass params use the attribute format `:param-name`

Example HTML:
<dynamic-frame :url="/some/url" :param-day="Monday"></dynamic-frame>
*/

/**
 * @class
 * @name DynamicFrame
 * @namespace DynamicFrame
 * @property url - The URL to fetch
 * @property executeScripts - If true will find and execute scripts in the response body
 * @property mode - The mode to use for adding the response content, either `replace`, `append` or `prepend` (Defaults to `replace`)
 * @property mountPoint - A selector used to find the element to mount to within the element (defaults to the root element)
 * @property autoRefresh - Will call `refresh()` automatically at the specified interval (Intervals are in the format `${num}${unit}` where unit is one of ms, s, m, h: `10s` = 10 seconds)
 * @property delay - An artificial delay applied before displaying the content
 * @property stateKey - An optional key, if specified the frame state will be stored and loaded from the page query string
 * @property contained - If `true` then the frame will be self contained, clicking links and submitting forms will be handled within the frame and **not** impact the surrounding page
 * @example
 *  <dynamic-frame :url="/some/url" :param-day="Monday" :mount-point=".content">
 *     <div class="content"></div>
 *  </dynamic-frame>
 */
class DynamicFrame extends Controller {
    /**
     * Setup the DynamicFrame and do the initial request/load
     * @memberof! DynamicFrame
     *
     */
    async init() {
        this.contents = "";

        // Keep track of pending requests so we can cancel when updating multiple things
        this._reqAbort = [];

        this.args.executeScripts = parseBoolean(this.args.executeScripts);

        if (this.args.autoRefresh) {
            this.setAutoRefresh();
        }

        if (!this.args.delay) this.args.delay = 0;

        // If we have a stateKey then track and handle the state
        if (this.args.stateKey) {
            const handleStateChange = () => {
                let frameState = this.loadState();

                // Update and refresh
                if (frameState && Object.keys(frameState).length > 0 && this._internal.frameState !== frameState) {
                    this.args.url = frameState[`${this.args.stateKey}-url`];
                    this._internal.frameState = frameState;
                    this.refresh();
                }
            };

            // Initial state load
            handleStateChange();

            // When the history state changes then reload our state
            // This is triggered when going back and forward in the browser
            window.addEventListener("popstate", () => handleStateChange());
            window.addEventListener("pushstate", () => handleStateChange());
        }

        this.containFrame(parseBoolean(this.args.contained));

        this.emit("dynamic-frame:init", {});
        if (this.renderOnInit) await this.loadContent();
    }

    /**
     * Reload the frame content then call `render()`
     * @memberof! DynamicFrame
     */
    async refresh(method = "get") {
        let ok = await this.loadContent(null, method);
        if (ok) await this.render();
    }

    /**
     * Call the base `bind()` and re-find the mountPoint in case it's changed
     * @memberof! DynamicFrame
     */
    bind() {
        super.bind();

        // Find the mount point
        if (this.args.mountPoint && typeof this.args.mountPoint === "string") {
            this.mountPoint = this.querySelector(this.args.mountPoint);
        }

        if (!this.mountPoint) {
            this.mountPoint = this.root;
        }
    }

    /**
     * Sets an interval to auto call `this.refresh()`
     * Overwrites previously set refresh intervals
     * @memberof! DynamicFrame
     */
    setAutoRefresh() {
        const interval = parseDuration(this.args.autoRefresh);

        if (interval === undefined) {
            console.error(`[${this.tag}] Undefined interval passed to setAutoRefresh`);
            return;
        }

        if (this._internal.autoRefreshInterval) {
            window.clearInterval(this._internal.autoRefreshInterval);
        }

        this._internal.autoRefreshInterval = window.setInterval(() => this.refresh(), interval);
    }

    /**
     * [async] Makes a new request and replaces or appends the response to the mountPoint
     * Returns true on success
     * Multiple calls will abort previous requests and return false
     * @returns boolean - true on success
     * @memberof! DynamicFrame
     */
    async loadContent(e, method = "get") {
        let url = this.endpoint();
        url.search = new URLSearchParams(this.params());

        // Keep track of all pending requests so we can abort them on duplicate calls
        this._reqAbort.forEach(controller => controller.abort());
        this._reqAbort = [];

        const abortController = new AbortController();
        this._reqAbort.push(abortController);

        let ok = true;
        const sendReq = async () => {
            try {
                let response = await fetch(url, {
                    signal: abortController.signal,
                    method: method,
                    headers: {
                        "X-Dynamic-Frame": 1,
                    },
                });

                // If no content then delete self
                if (response.status === 204) {
                    this.destroySelf();
                    ok = true;
                    return;
                }

                let text = await response.text();
                this.updateContent(text);
            } catch (err) {
                if (err.name !== "AbortError") {
                    throw err;
                }

                console.error(err);
                ok = false;
            }
        };

        await Promise.allSettled([new Promise(resolve => setTimeout(resolve, this.args.delay)), sendReq()]);

        if (ok) {
            this.saveState();
            this.bind(); // The new DOM content might need to be bound to the controller
        }

        this.emit("dynamic-frame:updated", {});
        return ok;
    }

    /**
     * Actually updates the content
     * This is where the artificial delay is applied
     * @param content - The content to use
     * @param mode - replace or append, defaults to `this.args.mode`
     * @memberof! DynamicFrame
     */
    updateContent(content, mode = null) {
        if (!mode) mode = this.args.mode || "replace";

        const template = document.createElement("template");
        template.innerHTML = content;

        // If we want to execute scripts then go through our template and turn script tags into real scripts
        if (this.args.executeScripts) {
            let scripts = template.content.querySelectorAll("script");

            [...scripts].forEach(script => {
                let newScript = document.createElement("script");

                // Copy all attributes to the new script
                [...script.attributes].forEach(attr => newScript.setAttribute(attr.name, attr.value));

                // Copy the content of the script tag
                if (script.innerHTML) newScript.appendChild(document.createTextNode(script.innerHTML));

                // Add the script tag back in
                script.replaceWith(newScript);
            });
        }

        if (mode === "replace") {
            this.mountPoint.replaceChildren(template.content);
        } else if (mode === "append") {
            this.mountPoint.appendChild(template.content);
        } else if (mode === "prepend") {
            this.mountPoint.prepend(template.content);
        }

        this.emit("frame-updated", { from: this, mode: mode });
    }

    /**
     * Returns the query string params for the request - expected to be overridden
     * Handles arrays as duplicated params (ie. a: [1,2] => ?a=1&a=2)
     * @returns {URLSearchParams}
     * @memberof! DynamicFrame
     */
    params(values = {}) {
        let params = new URLSearchParams(values);

        // Annoyingly URLSearchParams can't handle array params unless you call .append each time
        // So find any array params and re-add them manually
        Object.entries(values).forEach(([key, val]) => {
            if (Array.isArray(val)) {
                params.delete(key);
                val.forEach(item => params.append(key, item));
            }
        });

        for (let attr of this.attributes) {
            if (attr.nodeName.startsWith(":param-")) {
                params.append(attr.nodeName.substring(7), attr.nodeValue);
            }
        }
        return params;
    }

    /**
     * Set key/value pairs of params in the element attributes
     * @param {object} values
     */
    setParams(values = {}) {
        // Wipe out all current attributes
        for (let attr of this.attributes) {
            if (attr.nodeName.startsWith(":param-")) {
                this.removeAttribute(attr.nodeName);
            }
        }

        // Set the new params
        Object.entries(values).forEach(([key, val]) => {
            this.setAttribute(`:param-${key}`, val);
        });
    }

    /**
     * Returns the endpoint to call - from the :url attr on the root element
     * @returns {string}
     * @memberof! DynamicFrame
     */
    endpoint() {
        let url = this.args.url;

        if (!this.args.url) {
            console.error(`${this.tag}: No :url attribute specified`);
            return;
        }

        if (!url.startsWith("http")) url = window.location.origin + url;
        return new URL(url);
    }

    /**
     * Load the frame state based on the main page URL query string
     * @returns {object} The frame state
     */
    loadState() {
        if (!this.args.stateKey) return;

        let qs = window.location.search;

        if (!qs) return;
        qs = qs.substring(1);
        let qsParts = Object.fromEntries(qs.split("&").map(part => part.split("=")));

        let frameState = {};
        let params = {};
        for (let [key, value] of Object.entries(qsParts)) {
            if (key.startsWith(this.args.stateKey + "-")) {
                if (key.startsWith(this.args.stateKey + "-param-")) {
                    params[key.replace(this.stateKey + "-param-", "")] = value;
                }

                frameState[key] = value;
            }
        }

        // Update our params
        this.setParams(params);

        return frameState;
    }

    /**
     * Loads a URL into the frame by updating the url and param attributes and then reload
     * @param {*} url
     */
    loadUrl(url, method = "get") {
        let [origin, query] = url.split("?");
        if (!query) query = "";

        if (query) {
            const params = Object.fromEntries(query.split("&").map(part => part.split("=")));
            this.setParams(params);
        }

        this.args.url = origin;
        this.refresh(method);
    }

    /**
     * Save the frame state to the outer page URL query string and add to history
     * Only saves if the state has changed
     */
    saveState() {
        // If no stateKey then we can't save the state
        if (!this.args.stateKey) return;

        // Get the main page query string
        let mainPageQs = Object.fromEntries(new URLSearchParams(window.location.search));

        // Strip out any params that belong to this frame
        // We will re-add them below
        for (const key of Object.keys(mainPageQs)) {
            if (key.startsWith(`${this.args.stateKey}-`)) {
                delete mainPageQs[key];
            }
        }

        // Build our frame state object
        let frameState = {};
        frameState[`${this.args.stateKey}-url`] = this.args.url.replace(window.location.origin, "");

        // Add the params for this frame
        for (const [key, value] of this.params()) {
            frameState[`${this.args.stateKey}-param-${key}`] = value;
        }

        // Merge our frame state into the page params
        mainPageQs = { ...mainPageQs, ...frameState };

        // If our state changed then update the main page URL and add to history
        if (this._internal.frameState !== frameState) {
            const qs = Object.entries(mainPageQs)
                .map(part => `${part[0]}=${part[1]}`)
                .join("&");

            window.history.pushState(qs, "", `?${qs}`);
            this._internal.frameState = frameState;
        }
    }

    /**
     * Makes the frame self contained
     * Clicking any links or submitting any forms will only impact the frame, not the surrounding page
     * @param {bool} containAll Whether to automatically contain all `a` and `form` elements
     *                          If not set then it will be opt in per element.
     */
    containFrame(containAll = false) {
        // Capture all clicks and if it was on an <a> tag load the href within the frame
        this.addEventListener("click", e => {
            let target = e.target || e.srcElement;

            if (target.tagName === "A" && this.belongsToController(target)) {
                if (!containAll && !target.hasAttribute(":contained")) {
                    return;
                }

                e.preventDefault();
                const href = target.getAttribute("href");
                window.history.pushState({}, "", href);
                this.loadUrl(href);
            }
        });

        // Intercept form submits
        // To do this we need to submit the form ourselves
        // Aims to have near-full feature parity with regular HTML forms
        // We do not support the `target` attribute or the `method="dialog"` value
        // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form
        this.addEventListener("submit", async e => {
            if (!containAll && !e.target.hasAttribute(":contained")) {
                return;
            }

            e.preventDefault();
            e.stopPropagation();

            const method = e.target.getAttribute("method") || "GET";
            const action = e.target.getAttribute("action") || "/";
            const encoding = e.target.getAttribute("enctype") || "application/x-www-form-urlencoded";
            const skipValidation = e.target.getAttribute("novalidate") !== undefined;

            // Base HTML5 validation
            if (!skipValidation && !e.target.checkValidity()) {
                return;
            }

            // Build the form data to send
            const formData = new FormData(e.target);
            let params = new URLSearchParams();
            for (const pair of formData) {
                params.append(pair[0], pair[1]);
            }

            if (method.toUpperCase() == "POST") {
                let request = {
                    method: "POST",
                    headers: {
                        "X-Dynamic-Frame": 1,
                    },
                };

                if (encoding === "application/x-www-form-urlencoded") {
                    request.body = params;
                    request.headers["Content-Type"] = "application/x-www-form-urlencoded";
                } else {
                    // If sending as multipart then we omit the content-type
                    let multipartData = new FormData();
                    for (const pair of formData) {
                        multipartData.append(pair[0], pair[1]);
                    }

                    request.body = multipartData;
                }
                let response = await fetch(action, request);
                // Show the response body
                this.updateContent(await response.text());
            } else if (method.toUpperCase() == "GET") {
                const query = Object.fromEntries(new URLSearchParams(formData));
                this.setParams(query);
                this.args.url = action;
                window.history.pushState({}, "", action);
                this.refresh();
            }

            return false;
        });
    }

    /**
     * Remove self from DOM and remove state from query string
     */
    destroySelf() {
        this.parentElement.removeChild(this);

        if (this.args.stateKey) {
            // Get main page query string
            let qs = window.location.search;
            qs = qs.substring(1);
            if (!qs) return;

            // Remove any parts that belong to this frame
            let qsParts = Object.fromEntries(qs.split("&").map(part => part.split("=")));
            for (const [key, _value] of Object.entries(qsParts)) {
                if (key.startsWith(this.args.stateKey + "-")) {
                    delete qsParts[key];
                }
            }

            // Back to string and save
            qs = Object.entries(qsParts)
                .map(part => `${part[0]}=${part[1]}`)
                .join("&");

            window.history.pushState(qs, "", `?${qs}`);
        }
    }
}

/**
 * Container for route anchor elements
 * Any `<a>` elements within the `<dynamic-frame-router>` will be intercepted and only update the specified dynamic frame
 * Specifies the target `DynamicFrame` to handle routing for
 */
class DynamicFrameRouter extends Controller {
    async init() {
        this.target = document.querySelector(this.args.target);
        this.anchors = this.querySelectorAll("a");
        this.cache = {};
        this.args.caching = parseBoolean(this.args.caching);

        if (!this.target) {
            console.error(`Could not find target dynamic frame element: ${this.args.target}`);
            return;
        }

        // Handle clicks
        this.addEventListener("click", e => {
            let target = e.target || e.srcElement;

            if (target.tagName === "A" && this.belongsToController(target)) {
                e.preventDefault();
                this.navigate(target.getAttribute("href"), true);
            }
        });

        // Handle history change
        window.onpopstate = history.onpushstate = () => {
            this.navigate(document.location.pathname);
        };
    }

    async navigate(href, recordInHistory = false) {
        const targetUrl = new URL(href, window.location.origin);
        const oldHref = this.target.args.url;

        // Cache the contents of the frame
        if (this.args.caching) {
            this.cache[oldHref] = [...this.target.children].map(child => child.cloneNode(true));
        }

        // Update the targeted frame
        if (href in this.cache) {
            this.target.replaceChildren(...this.cache[href]);
            this.target.args.url = href;
        } else {
            await this.target.loadUrl(href);
        }

        // Update the active anchor
        this.anchors.forEach(a => {
            const anchorHref = new URL(a.href);

            if (anchorHref.pathname === targetUrl.pathname) {
                a.classList.add("active");
            } else {
                a.classList.remove("active");
            }
        });

        if (recordInHistory) window.history.pushState({}, "", href);
    }
}

export { DynamicFrame, DynamicFrameRouter };