view share/roundup/templates/classic/html/classhelper.js @ 8566:e4191aa7b402 default tip

doc: issue2551415 correct doc for change input->input_payload in 2.5 the rest interface changed a variable name from input to input_payload. An earlier commit changed the rest docs. This commit adds an item for it to the upgrading 2.4.0->2.5.0 section. Also cross reference added to the rest docs with the updated examples.
author John Rouillard <rouilj@ieee.org>
date Thu, 09 Apr 2026 00:19:06 -0400
parents a0ca6b6a8cea
children
line wrap: on
line source

/**
 * Properties for the ClassHelper component, 
 * made into a type for better readability.
 * @typedef {Object} HelpUrlProps
 * Type of data that needs to be shown (eg. issue, user, keywords) parsed from helpurl
 * @property {string} apiClassName
 * @property {number} width // width of the popup window
 * @property {number} height // height of the popup window
 * The form on which the classhelper is being used
 * @property {string | null} formName
 * The form property on which the classhelper is being used
 * @property {string | null} formProperty
 * @property {string | null} tableSelectionType // it has to be "checkbox" or "radio"(if any)
 * The fields on which the table is sorted
 * @property {string[] | undefined} sort
 * The actual fields to be displayed in the table
 * @property {string[] | undefined} fields
 * @property {number} pageIndex
 * @property {number} pageSize
 */


// change this to true to disable the classhelper
const DISABLE_CLASSHELPER = false

// Let user customize the css file name
const CSS_STYLESHEET_FILE_NAME = "@@file/classhelper.css";

const CLASSHELPER_TAG_NAME = "roundup-classhelper";
const CLASSHELPER_ATTRIBUTE_SEARCH_WITH = "data-search-with";
const CLASSHELPER_ATTRIBUTE_POPUP_TITLE = "data-popup-title";
const CLASSHELPER_ATTRIBUTE_POPUP_TITLE_ITEM_CLASS_LOOKUP = "{className}";
const CLASSHELPER_ATTRIBUTE_POPUP_TITLE_ITEM_DESIGNATOR_LOOKUP = "{itemDesignator}";
const CLASSHELPER_POPUP_FEATURES = (width, height) => `popup=yes,width=${width},height=${height}`;
const CLASSHELPER_POPUP_URL = "about:blank";
const CLASSHELPER_POPUP_TARGET = "_blank";

const CLASSHELPER_READONLY_POPUP_TITLE = "Info on {className} - {itemDesignator} - Classhelper"
const CLASSHELPER_TABLE_SELECTION_NONE = "table-selection-none";
const CLASSHELPER_TRANSLATION_KEYWORDS = ["apply", "cancel", "next", "prev", "search", "reset", CLASSHELPER_READONLY_POPUP_TITLE ];

const ALTERNATIVE_DROPDOWN_PATHNAMES = {
    "roles": "/rest/data/user/roles"
}

/**
 * This is a custom web component(user named html tag) that wraps a helpurl link 
 * and provides additional functionality.
 * 
 * The classhelper is an interactive popup window that displays a table of data.
 * Users can interact with this window to search, navigate and select data from the table.
 * The selected data is either "Id" or "a Value" from the table row.
 * There can be multiple selections in the table.
 * The selected data is then populated in a form field in the main window.
 * 
 * How to use.
 * ------------
 * The helpurl must be wrapped under this web component(user named html tag).
 * ```html
 * <roundup-classhelper data-popup-title="info - {itemDesignator} - Classhelper" data-search-with="title,status[],keyword[]+name">
 *   ( helpurl template here, this can be tal, chameleon, jinja2.
 *     In HTML DOM this is an helpurl anchor tag.
 *    )
 * </roundup-classhelper>
 * ```
 * 
 * The data-search-with attribute of the web component is optional.
 * 
 * data-search-with attribute value is a list of comma separated names of table data fields.
 * (It is possible that a data field is present in search form but absent in the table).
 * 
 * A square parentheses open+close ("[]") can be added to the column name eg."status[]",
 * this will make that search field as a dropdown in the search form in popup window, 
 * then a user can see all the possible values that column can have.
 * 
 * eg. data-search-with="title,status[],keyword[]+name" where status can have values like "open", 
 * "closed" a dropdown will be shown with null, open and closed. This is an aesthetic usage 
 * instead of writing in a text field for options in status.
 * 
 * A plus sign or minus sign with data field can be used to specify the sort order of the dropdown.
 * In the above example, keyword[]+name will sort the dropdown in ascending order(a-z) of name of the keyword.
 * A value keyword[]-name will sort the dropdown in descending order(z-a) of name of the keyword.
 * 
 * data-search-with="<<column name>>[]{+|-}{id|name}"
 * Here column name is required,
 * optionally there can be [] for a dropdown,
 * optionally with "[]" present to a column name there can be 
 * [+ or -] with succeeding "id" or "name" for sorting dropdown.
 * 
 * The data-popup-title attribute of the web component is optional.
 * the value of this attribute is the title of the popup window.
 * the user can use "{itemDesignator}" in the title to replace in the attribute value.
 * and the current context of classhelper will replace "{itemDesignator}".
 * 
 */
class ClassHelper extends HTMLElement {

    static observedAttributes = [CLASSHELPER_ATTRIBUTE_SEARCH_WITH]

    /** @type {Window} handler to popup window */
    popupRef = null;

    /** 
     * Result from making a call to the rest api, for the translation keywords.
     * @type {Object.<string, string>} */
    static translations = null;

    /** 
     * Stores the result from api calls made to rest api,
     * for the parameters in data-search-with attribute of this web component
     * where a parameter is defined as a dropdown in 
     * @type {Object.<string, Map.<string, string>>} */
    dropdownsData = null;

    /** @type {HTMLAnchorElement} */
    helpurl = null;

    /** @type {string} */
    helpurlScript = null;

    /** @type {HelpUrlProps} */
    helpurlProps = null;

    /** 
 * The qualified domain name with protocol and port(if any)
 * with the tracker name if any.
 * eg. http://localhost:8080/demo or https://demo.roundup-tracker.org
 * @type {string} */
    trackerBaseURL = null;

    /** no-op function */
    preventDefault = e => e.preventDefault();

    connectedCallback() {
        try {
            this.helpurl = this.findClassHelpLink();

            // Removing the helpurl click behavior
            this.helpurlScript = this.helpurl.getAttribute("onclick");
            this.helpurl.removeAttribute("onclick", "");
            this.helpurl.addEventListener("click", this.preventDefault);

            this.helpurlProps = ClassHelper.parseHelpUrlProps(this.helpurl);

            this.trackerBaseURL = window.location.href.substring(0, window.location.href.lastIndexOf("/"));

        } catch (err) {
            console.warn("Classhelper not intercepting helpurl.");
            if (this.helpurl != null) {
                this.helpurl.removeEventListener("click", this.preventDefault);
                this.helpurl.setAttribute("onclick", this.helpurlScript);
            }
            console.error(err);
            return;
        }

        const initialRequestURL = ClassHelper.getRestURL(this.trackerBaseURL, this.helpurlProps);

        this.fetchDropdownsData()
            .catch(error => {
                // Top level handling for dropdowns errors.
                console.error(error);
            });

        const cleanUpClosure = () => {
            console.warn("Classhelper not intercepting helpurl.");
            this.removeEventListener("click", handleClickEvent);
            this.helpurl.removeEventListener("click", this.preventDefault);
            this.helpurl.setAttribute("onclick", this.helpurlScript);
        }

        const handleClickEvent = (event) => {
            if (this.popupRef != null && !this.popupRef.closed) {
                this.popupRef.focus();
                return;
            }

            this.openPopUp(initialRequestURL, this.helpurlProps)
                .catch(error => {
                    // Top level error handling for openPopUp method.
                    cleanUpClosure();
                    console.error(error);
                    if (this.popupRef != null) {
                        this.popupRef.close();
                    }
                    window.alert("Error: Failed to open classhelper, check console for more details.");
                    this.helpurl.click();
                });
        };

        const handlePopupReadyEvent = (event) => {
            // we get a document Fragment in event.detail we replace it with the root
            // replaceChild method consumes the documentFragment content, subsequent calls will be no-op.
            if (event.detail.childElementCount === 1) {
                this.popupRef.document.replaceChild(event.detail, this.popupRef.document.documentElement);
            }
        }

        const handleNextPageEvent = (event) => {
            this.pageChange(event.detail.value, this.helpurlProps)
                .catch(error => {
                    // Top level error handling for nextPage method.
                    cleanUpClosure();
                    console.error(error, `request data url: ${event.detail.value}`);
                    if (this.popupRef != null) {
                        this.popupRef.close();
                    }
                    window.alert("Error: Failed to load next page, check console for more details.");
                    this.helpurl.click();
                });
        }

        const handlePrevPageEvent = (event) => {
            this.pageChange(event.detail.value, this.helpurlProps)
                .catch(error => {
                    // Top level error handling for prevPage method.
                    cleanUpClosure();
                    console.error(error, `request data url: ${event.detail.value}`);
                    if (this.popupRef != null) {
                        this.popupRef.close();
                    }
                    window.alert("Error: Failed to load next page, check console for more details.");
                    this.helpurl.click();
                });
        }

        const handleValueSelectedEvent = (event) => {
            // does not throw error
            this.valueSelected(this.helpurlProps, event.detail.value);
        }

        const handleSearchEvent = (event) => {
            this.helpurlProps.pageIndex = 1;
            const searchURL = ClassHelper.getSearchURL(this.trackerBaseURL, this.helpurlProps, event.detail.value);
            this.searchEvent(searchURL, this.helpurlProps)
                .catch(error => {
                    // Top level error handling for searchEvent method.
                    cleanUpClosure();
                    console.error(error, `request data url: ${event.detail.value}`);
                    if (this.popupRef != null) {
                        this.popupRef.close();
                    }
                    window.alert("Error: Failed to load next page, check console for more details.");
                    this.helpurl.click();
                });
        }

        const handleSelectionEvent = (event) => {
            // does not throw error
            this.selectionEvent(event.detail.value);
        }

        this.addEventListener("click", handleClickEvent);
        this.addEventListener("popupReady", handlePopupReadyEvent);
        this.addEventListener("prevPage", handlePrevPageEvent);
        this.addEventListener("nextPage", handleNextPageEvent);
        this.addEventListener("valueSelected", handleValueSelectedEvent);
        this.addEventListener("search", handleSearchEvent);
        this.addEventListener("selection", handleSelectionEvent);
    }

    attributeChangedCallback(name, oldValue, _newValue) {
        if (name === CLASSHELPER_ATTRIBUTE_SEARCH_WITH) {
            if (!oldValue || oldValue === _newValue) {
                return;
            }
            this.fetchDropdownsData().catch(error => {
                // Top level handling for dropdowns errors.
                console.error(error.message);
            });

            let oldForm = this.popupRef.document.getElementById("popup-search");
            let newForm = this.getSearchFragment();
            this.popupRef.document.body.replaceChild(newForm, oldForm);
        }
    }

    static async fetchTranslations() {
        // Singleton implementation
        if (ClassHelper.translations != null) {
            return;
        }

        const keys = new Set();

        const classhelpers = document.getElementsByTagName(CLASSHELPER_TAG_NAME);
        for (let classhelper of classhelpers) {
            if (classhelper.dataset.searchWith) {
                classhelper.dataset.searchWith
                    .split(',')
                    .forEach(param => {
                        keys.add(param.split("[]")[0]);
                    });
            }

	    if (classhelper.dataset.popupTitle) {
		keys.add(classhelper.dataset.popupTitle)
	    }

            const a = classhelper.querySelector("a");
            if (a && a.dataset.helpurl) {
                let searchParams = new URLSearchParams(a.dataset.helpurl.split("?")[1]);
                let properties = searchParams.get("properties");
                if (properties) {
                    properties.split(',').forEach(key => keys.add(key));
                }
            }
        }

        CLASSHELPER_TRANSLATION_KEYWORDS.forEach(key => keys.add(key));

        ClassHelper.translations = {};
        for (let key of keys) {
            ClassHelper.translations[key] = key;
        }

        let tracker_path = window.location.pathname.split('/').slice(0,-1).join('/')
        let url = new URL(window.location.origin + tracker_path + '/');
        url.searchParams.append("@template", "translation");
        url.searchParams.append("properties", Array.from(keys.values()).join(','));

        let resp, json;

        try {
            resp = await fetch(url);
        } catch (error) {
            let message = `Error fetching translations from roundup rest api\n`;
            message += `url: ${url.toString()}\n`;
            throw new Error(message, { cause: error });
        }

        try {
            json = await resp.json();
        } catch (error) {
            let message = `Error parsing json from roundup rest api\n`;
            message += `url: ${url.toString()}\n`;
            throw new Error(message, { cause: error });
        }

        if (!resp.ok) {
            let message = `Unexpected response\n`;
            message += `url: ${url.toString()}\n`;
            message += `response status: ${resp.status}\n`;
            message += `response body: ${JSON.stringify(json)}\n`;
            throw new Error(message);
        }

        for (let entry of Object.entries(json)) {
            ClassHelper.translations[entry[0]] = entry[1];
        }
    }

    async fetchDropdownsData() {
        // Singleton implementation
        if (this.dropdownsData != null) {
            return;
        }
        this.dropdownsData = {};

        if (this.dataset.searchWith == null) {
            return;
        }

        const params = this.dataset.searchWith.split(',');

        for (let param of params) {
            if (param.includes("[]")) {
                const segments = param.split("[]");
                param = segments[0];
                const sortOrder = segments[1];

                let url = this.trackerBaseURL;
                if (ALTERNATIVE_DROPDOWN_PATHNAMES[param]) {
                    url += ALTERNATIVE_DROPDOWN_PATHNAMES[param];
                } else {
                    url += `/rest/data/${param}`;
                }
                url += "?@verbose=2";

                if (sortOrder) {
                    url += `&@sort=${sortOrder}`;
                }

                let resp, json;
                try {
                    resp = await fetch(url);
                } catch (error) {
                    let message = `Error fetching translations from roundup rest api\n`;
                    message += `url: ${url.toString()}\n`;
                    throw new Error(message, { cause: error });
                }

                try {
                    json = await resp.json();
                } catch (error) {
                    let message = `Error parsing json from roundup rest api\n`;
                    message += `url: ${url.toString()}\n`;
                    throw new Error(message, { cause: error });
                }

                if (!resp.ok) {
                    let message = `Unexpected response\n`;
                    message += `url: ${url.toString()}\n`;
                    message += `response status: ${resp.status}\n`;
                    message += `response body: ${JSON.stringify(json)}\n`;
                    throw new Error(message);
                }

                let list = new Map();

                if (json.data.collection.length > 0) {
                    let idKey = "id";
                    let valueKey = Object.keys(json.data.collection[0]).find(key => key !== "id" && key !== "link");

                    if (!valueKey) {
                        let message = `No suitable key found for value in dropdown data\n`;
                        message += `url: ${url.toString()}\n`;
                        throw new Error("No value key found in dropdown data for: " + url);
                    }

                    for (let entry of json.data.collection) {
                        list.set(entry[idKey], entry[valueKey]);
                    }

                }
                this.dropdownsData[param] = list;
            }
        }
    }

    /**
     * Find the anchor tag that provides the classhelp link.
     * @returns {HTMLAnchorElement}
     * @throws {Error} when the anchor tag is not classhelp link
     */
    findClassHelpLink() {
        const links = this.querySelectorAll("a");
        if (links.length != 1) {
            throw new Error("roundup-classhelper must wrap a single classhelp link");
        }
        const link = links.item(0);

        if (!link.dataset.helpurl) {
            throw new Error("roundup-classhelper link must have a data-helpurl attribute");
        }

        if (!link.dataset.width) {
            throw new Error("roundup-classhelper link must have a data-width attribute");
        }

        if (!link.dataset.height) {
            throw new Error("roundup-classhelper link must have a data-height attribute");
        }

        if (!link.getAttribute("onclick")) {
            throw new Error("roundup-classhelper link should have an onclick attribute set");
        }

        return link;
    }

    /**
     * This method parses the helpurl link to get the necessary data for the classhelper.
     * @param {HTMLAnchorElement} link
     * @returns {HelpUrlProps}
     * @throws {Error} when the helpurl link is not proper
     */
    static parseHelpUrlProps(link) {
        const width = parseInt(link.dataset.width);
        if (isNaN(width)) {
            throw new Error("width in helpurl must be a number");
        }

        const height = parseInt(link.dataset.height);
        if (isNaN(height)) {
            throw new Error("height in helpurl must be a number");
        }

        const urlParts = link.dataset.helpurl.split("?");

        if (urlParts.length != 2) {
            throw new Error("invalid helpurl from link, missing query params");
        }

        const apiClassName = urlParts[0];
        const searchParams = new URLSearchParams(urlParts[1]);

        const tableSelectionType = searchParams.get("type");
        const formName = searchParams.get("form");
        const formProperty = searchParams.get("property");

        const startWith = parseInt(searchParams.get("@startwith"));
        if (isNaN(startWith)) {
            throw new Error("startwith in helpurl must be a number");
        }

        const pageIndex = startWith + 1;
        const pageSize = parseInt(searchParams.get("@pagesize"));

        if (isNaN(pageSize)) {
            throw new Error("pagesize in helpurl must be a number");
        }

        const sort = searchParams.get("@sort")?.split(",");
        const fields = searchParams.get("properties")?.split(",");

        return {
            width,
            height,
            apiClassName,
            tableSelectionType,
            formName,
            formProperty,
            pageIndex,
            pageSize,
            sort,
            fields
        }
    }

    /** 
     * from roundup docs rest api url - "{host}/{tracker}
     * we pass helpurl which is parsed from anchor tag and return a URL.
     * @param {HelpUrlProps} props
     * @returns {URL}
     */
    static getRestURL(trackerBaseURL, props) {
        const restDataPath = "rest/data";
        const base = trackerBaseURL + "/" + restDataPath + "/" + props.apiClassName;
        let url = new URL(base);

        url.searchParams.append("@page_index", props.pageIndex);
        url.searchParams.append("@page_size", props.pageSize);
        let fields = props.fields.join(',');
        url.searchParams.append("@fields", fields);

        if (props.sort) {
            let sort = props.sort.join(',');
            url.searchParams.append("@sort", sort);
        }

        return url;
    }

    static getSearchURL(trackerBaseURL, props, formData) {
        const url = new URL(ClassHelper.getRestURL(trackerBaseURL, props).toString());
        for (let entry of formData.entries()) {
            if (entry[1] != null && entry[1] != "") {
                url.searchParams.append(entry[0], entry[1]);
            }
        }
        return url;
    }

    getSearchFragment(formData) {
        const fragment = document.createDocumentFragment();
        const form = document.createElement("form");
        form.setAttribute("id", "popup-search");
        form.classList.add("popup-search"); // Add class for styling

        const params = this.dataset.searchWith.split(',');

        const table = document.createElement("table");
        table.classList.add("search-table"); // Add class for styling
        table.setAttribute("role", "presentation");

        for (var param of params) {
            param = param.split("[]")[0];

            const row = document.createElement("tr");
            const labelCell = document.createElement("td");
            const inputCell = document.createElement("td");

            const label = document.createElement("label");
            label.classList.add("search-label"); // Add class for styling
            label.setAttribute("for", param);
            label.textContent = ClassHelper.translations[param] + ":";

            let focusSet = false
            let input;
            if (this.dropdownsData[param]) {
                input = document.createElement("select");

                let nullOption = document.createElement("option");
                nullOption.value = "";
                nullOption.textContent = "---";
                input.appendChild(nullOption);

                for (let key of this.dropdownsData[param].keys()) {
                    let option = document.createElement("option");
                    option.value = key;
                    option.textContent = this.dropdownsData[param].get(key);
                    if (formData) {
                        let value = formData.get(param);
                        if (value && value == key) {
                            option.selected = "selected";
                        }
                    }
                    input.appendChild(option);
                }
            } else {
                input = document.createElement("input");
                input.setAttribute("type", "text");
                input.setAttribute("autocapitalize", "off")

                if (formData) {
                    let value = formData.get(param);
                    if (value) {
                        input.value = value;
                    }
                }
            }

            input.setAttribute("name", param);
            input.setAttribute("id", param);
            input.classList.add("search-input"); // Add class for styling   
	    if (!focusSet) {
	      input.setAttribute("autofocus", "");
	      focusSet = true;
	    }

            labelCell.appendChild(label);
            inputCell.appendChild(input);

            row.appendChild(labelCell);
            row.appendChild(inputCell);

            table.appendChild(row);
        }

        // Add search and reset buttons
        const buttonRow = document.createElement("tr");
        const emptyButtonCell = document.createElement("td");
        const buttonCell = document.createElement("td");
        buttonCell.colSpan = 1;

        const search = document.createElement("button");
        search.textContent = ClassHelper.translations["search"];
        search.classList.add("search-button"); // Add class for styling
        search.addEventListener("click", (e) => {
            e.preventDefault();
            let fd = new FormData(form);

            let hasError = this.popupRef.document.getElementsByClassName("search-error").item(0);
            if (hasError != null) {
                let current = fd.get(hasError.dataset.errorField)
                let prev = hasError.dataset.errorValue;
                if (current === prev) {
                    return;
                }
            }

            this.dispatchEvent(new CustomEvent("search", {
                detail: {
                    value: fd
                }
            }));
        });

        const reset = document.createElement("button");
        reset.textContent = ClassHelper.translations["reset"];
        reset.classList.add("reset-button"); // Add class for styling
        reset.addEventListener("click", (e) => {
            e.preventDefault();
            form.reset();
            let fd = new FormData(form);
            this.dispatchEvent(new CustomEvent("search", {
                detail: {
                    value: fd
                }
            }));
        });

        buttonCell.appendChild(search);
        buttonCell.appendChild(reset);
        buttonRow.appendChild(emptyButtonCell);
        buttonRow.appendChild(buttonCell);

        table.appendChild(buttonRow);

        form.appendChild(table);
        fragment.appendChild(form);

        return fragment;
    }

    getPaginationFragment(prevUrl, nextUrl, index, size, total) {
        const fragment = document.createDocumentFragment();

        const container = document.createElement("div");
        container.id = "popup-pagination";
        container.classList.add("popup-pagination");

        const info = document.createElement('span');

        let startNumber = 0, endNumber = 0;

        if (total > 0) {
            startNumber = (parseInt(index) - 1) * parseInt(size) + 1;
            if (total < size) {
                endNumber = startNumber + total - 1;
            } else {
                endNumber = parseInt(index) * parseInt(size);
            }
        }

        info.textContent = `${startNumber} - ${endNumber}`;

        const prev = document.createElement("button");
        prev.innerHTML = "<";
        prev.setAttribute("aria-label", ClassHelper.translations["prev"]);
        prev.setAttribute("disabled", "disabled");
        if (prevUrl) {
            prev.removeAttribute("disabled");
            prev.addEventListener("click", () => {
                this.dispatchEvent(new CustomEvent("prevPage", {
                    detail: {
                        value: prevUrl
                    }
                }));
            });
        }

        const next = document.createElement("button");
        next.innerHTML = ">";
        next.setAttribute("aria-label", ClassHelper.translations["next"]);
        next.setAttribute("disabled", "disabled");
        if (nextUrl) {
            next.removeAttribute("disabled");
            next.addEventListener("click", () => {
                this.dispatchEvent(new CustomEvent("nextPage", {
                    detail: {
                        value: nextUrl
                    }
                }));
            });
        }

        container.append(prev, info, next);
        fragment.appendChild(container);
        return fragment;
    }

    getAccumulatorFragment(preSelectedValues) {
        const fragment = document.createDocumentFragment();
        const container = document.createElement("div");
        container.id = "popup-control";

        const form = document.createElement("form")
        form.id = "accumulator-form"
        form.classList.add("popup-control");

        const preview = document.createElement("input");
        preview.id = "popup-preview";
        preview.classList.add("popup-preview");
        preview.type = "text";
        preview.name = "preview";
        if (preSelectedValues.length > 0) {
            preview.value = preSelectedValues.join(',');
        }

        const cancel = document.createElement("button");
        cancel.textContent = ClassHelper.translations["cancel"];
        cancel.setAttribute("type", "button");
        cancel.addEventListener("click", () => {
            this.popupRef.close();
        });

        const apply = document.createElement("button");
        apply.id = "popup-apply";
        apply.classList.add("popup-apply");
        apply.textContent = ClassHelper.translations["apply"];
        apply.addEventListener("click", () => {
            this.dispatchEvent(new CustomEvent("valueSelected", {
                detail: {
                    value: preview.value
                }
            }))
        })

        form.append(preview, apply, cancel);
        container.append(form)
        fragment.appendChild(container);

        return fragment;
    }

    /**
     * 
     * @param {string[]} headers 
     * @param {Object.<string, any>[]} data 
     * @returns 
     */
    getTableFragment(headers, data, preSelectedValues) {
        let includeCheckbox = !this.popupRef.document.body.classList.contains(CLASSHELPER_TABLE_SELECTION_NONE);

        const fragment = document.createDocumentFragment();

        const container = document.createElement('div');
        container.id = "popup-tablediv";
        container.classList.add("popup-tablediv");

        const table = document.createElement('table');
        table.classList.add("popup-table");
        const thead = document.createElement('thead');
        const tbody = document.createElement('tbody');
        const tfoot = document.createElement('tfoot');

        // Create table headers
        const headerRow = document.createElement('tr');

        if (includeCheckbox) {
            let thx = document.createElement("th");
            thx.textContent = "X";
            thx.classList.add("table-header");
            headerRow.appendChild(thx);
        }

        headers.forEach(header => {
            const th = document.createElement('th');
            th.textContent = ClassHelper.translations[header];
            headerRow.appendChild(th);
        });
        thead.appendChild(headerRow);

        // Create table body with data
        data.forEach((entry) => {
            const row = document.createElement('tr');
            row.dataset.id = entry[headers[0]];
            row.setAttribute("tabindex", 0);
            row.classList.add("row-style");

            if (includeCheckbox) {
                const td = document.createElement('td');
                const checkbox = document.createElement("input");
                checkbox.setAttribute("type", "checkbox");
                checkbox.checked = false;
                checkbox.setAttribute("tabindex", -1);
                td.appendChild(checkbox)
                row.appendChild(td);
                if (preSelectedValues.includes(entry[headers[0]])) {
                    checkbox.checked = true;
                }
            }

            headers.forEach(header => {
                const td = document.createElement('td');
                td.textContent = entry[header];
                row.appendChild(td);
            });
            tbody.appendChild(row);
        });

        if (includeCheckbox) {
            tbody.addEventListener("click", (e) => {
                let id, tr;
                if (e.target.tagName === "INPUT" ) {
                    tr = e.target.parentElement.parentElement;
                    id = tr.dataset.id;
	        } else if (e.target.tagName === "TD") {
                    tr = e.target.parentElement;
                    id = tr.dataset.id;
                } else if (e.target.tagName === "TR") {
                    tr = e.target;
                    id = tr.dataset.id;
                }

              if (e.target.tagName !== "INPUT") {
		/* checkbox is only child of the first td of the table row */
		let checkbox = tr.children.item(0).children.item(0);
		checkbox.checked = !checkbox.checked;
                }

                this.dispatchEvent(new CustomEvent("selection", {
                    detail: {
                        value: id
                    }
                }));
            });

        }

        // Create table footer with the same column values as headers
        const footerRow = headerRow.cloneNode(true);
        tfoot.appendChild(footerRow);

        // Assemble the table
        table.appendChild(thead);
        table.appendChild(tbody);
        table.appendChild(tfoot); // Append the footer

        container.appendChild(table);

        fragment.appendChild(container);

        return fragment;
    }

    /**
     * main method called when classhelper is clicked
     * @param {URL | string} apiURL
     * @param {HelpUrlProps} props 
     * @param {string[]} preSelectedValues
     * @param {FormData} formData
     * @throws {Error} when fetching or parsing data from roundup rest api fails
     */
    async openPopUp(apiURL, props) {

        /** @type {Response} */
        let resp, json;
        /** @type {any} */
        let collection;
        /** @type {string} */
        let prevPageURL;
        /** @type {string} */
        let nextPageURL;
        /** @type {string[]} */
        let preSelectedValues = [];

        if (document.URL.endsWith("#classhelper-abort")) {
          throw new Error("Aborting due to #classhelper-abort fragment",
			  { cause: "Abort requested." });
        }

        try {
            resp = await fetch(apiURL);
        } catch (error) {
            let message = `Error fetching data from roundup rest api`;
            message += `url: ${apiURL.toString()}\n`;
            throw new Error(message, { cause: error });
        }

        try {
            json = await resp.json();
        } catch (error) {
            let message = "Error parsing json from roundup rest api\n";
            message += `url: ${apiURL.toString()}\n`;
            throw new Error(message, { cause: error });
        }

        if (!resp.ok) {
            let message = `Unexpected response\n`;
            message += `url: ${apiURL.toString()}\n`;
            message += `response status: ${resp.status}\n`;
            message += `response body: ${JSON.stringify(json)}\n`;
            throw new Error(message);
        }

        collection = json.data.collection;

        const links = json.data["@links"];
        if (links?.prev?.length > 0) {
            prevPageURL = links.prev[0].uri;
        }
        if (links?.next?.length > 0) {
            nextPageURL = links.next[0].uri;
        }

        if (props.formProperty) {
            // Find preselected values
            const input = document.getElementsByName(props.formProperty).item(0);
            if (input?.value) {
                preSelectedValues = input.value.split(',');
            }
        }

        const popupFeatures = CLASSHELPER_POPUP_FEATURES(props.width, props.height);
        this.popupRef = window.open(CLASSHELPER_POPUP_URL, CLASSHELPER_POPUP_TARGET, popupFeatures);

        if (this.popupRef == null) {
            throw new Error("Browser Failed to open Popup Window");
        }

        // Create the popup root level page
        const page = document.createDocumentFragment();
        const html = document.createElement("html");
        const head = document.createElement("head");
        const body = document.createElement("body");

        body.classList.add("flex-container");
        if (!props.formProperty) {
            this.popupRef.document.body.classList.add(CLASSHELPER_TABLE_SELECTION_NONE);
            body.classList.add(CLASSHELPER_TABLE_SELECTION_NONE);
        }

        const itemDesignator = window.location.pathname.split("/").at(-1);
        let titleText;

        if (this.dataset.popupTitle) {
            titleText = ClassHelper.translations[this.dataset.popupTitle];
            titleText = titleText.replace(
		CLASSHELPER_ATTRIBUTE_POPUP_TITLE_ITEM_DESIGNATOR_LOOKUP,
		itemDesignator);
        } else {
            titleText = `${itemDesignator} - Classhelper`;
            if (props.formProperty) {
                // use the formProperty as the label for the window
                titleText = props.formProperty + " - " + titleText;
            } else if (props.apiClassName) {
                titleText = ClassHelper.translations[
                    CLASSHELPER_READONLY_POPUP_TITLE
		].replace(
		    CLASSHELPER_ATTRIBUTE_POPUP_TITLE_ITEM_DESIGNATOR_LOOKUP,
		    itemDesignator);
                titleText = titleText.replace(
		    CLASSHELPER_ATTRIBUTE_POPUP_TITLE_ITEM_CLASS_LOOKUP,
		    props.apiClassName);
            }
        }

        const titleTag = document.createElement("title");
        titleTag.textContent = titleText;
        head.appendChild(titleTag);

        const styleSheet = document.createElement("link");
        styleSheet.rel = "stylesheet";
        styleSheet.type = "text/css";
        styleSheet.href = this.trackerBaseURL + '/' + CSS_STYLESHEET_FILE_NAME;
        head.appendChild(styleSheet);

        if (this.dataset.searchWith) {
            const searchFrag = this.getSearchFragment(null);
            body.appendChild(searchFrag);
        }

        const paginationFrag = this.getPaginationFragment(prevPageURL, nextPageURL, props.pageIndex, props.pageSize, collection.length);
        body.appendChild(paginationFrag);

        const tableFrag = this.getTableFragment(props.fields, collection, preSelectedValues);
        body.appendChild(tableFrag);

        const separator = document.createElement("div");
        separator.classList.add("separator");
        body.appendChild(separator);

        if (props.formProperty) {
            const accumulatorFrag = this.getAccumulatorFragment(preSelectedValues);
            body.appendChild(accumulatorFrag);
        }

        html.appendChild(head);
        html.appendChild(body);
        page.appendChild(html);

        const dispatchPopupReady = () => this.dispatchEvent(new CustomEvent("popupReady", { detail: page }));

        // Wait for the popup window to load, onload fire popupReady event on the classhelper
        this.popupRef.addEventListener("load", dispatchPopupReady);

        // If load event was already fired way before the event listener was attached
        // we need to trigger it manually if popupRef is readyState complete
        if (this.popupRef.document.readyState === "complete") {
            dispatchPopupReady();
            // if we did successfully trigger the event, we can remove the event listener
            // else wait for it to be removed with closing of popup window, this cleaning up closure
            this.popupRef.removeEventListener("load", dispatchPopupReady);
        }

        this.popupRef.addEventListener("keydown", (e) => {
            if (e.key === "ArrowDown") {
                if (e.target.tagName === "TR") {
                    e.preventDefault();
                    if (e.target.nextElementSibling != null) {
                        e.target.nextElementSibling.focus();
                    } else {
                        e.target.parentElement.firstChild.focus();
                    }
                } else if (e.target.tagName != "INPUT" && e.target.tagName != "SELECT") {
                    e.preventDefault();
                    this.popupRef.document.querySelector("tr.row-style").parentElement.firstChild.focus();
                }
            } else if (e.key === "ArrowUp") {
                if (e.target.tagName === "TR") {
                    e.preventDefault();
                    if (e.target.previousElementSibling != null) {
                        e.target.previousElementSibling.focus();
                    } else {
                        e.target.parentElement.lastChild.focus();
                    }
                } else if (e.target.tagName != "INPUT" && e.target.tagName != "SELECT") {
                    e.preventDefault();
                    this.popupRef.document.querySelector("tr.row-style").parentElement.lastChild.focus();
                }
            } else if (e.key === ">") {
                if (e.target.tagName === "TR" || e.target.tagName != "INPUT" && e.target.tagName != "SELECT") {
                    this.popupRef.document.getElementById("popup-pagination").lastChild.focus();
                }
            } else if (e.key === "<") {
                if (e.target.tagName === "TR" || e.target.tagName != "INPUT" && e.target.tagName != "SELECT") {
                    this.popupRef.document.getElementById("popup-pagination").firstChild.focus();
                }
            } else if (e.key === "Enter") {
                if (e.target.tagName == "TR" && e.shiftKey == false) {
                    e.preventDefault();
                    let tr = e.target;
                    let checkbox = tr.children.item(0).children.item(0)
                    checkbox.checked = !checkbox.checked;
                    this.dispatchEvent(new CustomEvent("selection", {
                        detail: {
                            value: tr.dataset.id
                        }
                    }));
                } else if (e.shiftKey) {
                    e.preventDefault();
                    const applyBtn = this.popupRef.document.getElementById("popup-apply");
                    if (applyBtn) {
                        applyBtn.focus();
                    }
                }
            } else if (e.key === " ") {
                if (e.target.tagName == "TR" && e.shiftKey == false) {
                    e.preventDefault();
                    let tr = e.target;
                    let checkbox = tr.children.item(0).children.item(0)
                    checkbox.checked = !checkbox.checked;
                    this.dispatchEvent(new CustomEvent("selection", {
                        detail: {
                            value: tr.dataset.id
                        }
                    }));
                }
            }
        });
    }

    /** method when next or previous button is clicked
     * @param {URL | string} apiURL
     * @param {HelpUrlProps} props
     * @throws {Error} when fetching or parsing data from roundup rest api fails
     */
    async pageChange(apiURL, props) {

        /** @type {Response} */
        let resp, json;
        /** @type {any} */
        let collection;
        /** @type {string} */
        let prevPageURL;
        /** @type {string} */
        let nextPageURL;
        /** @type {URL} */
        let selfPageURL;
        /** @type {string[]} */
        let accumulatorValues = [];

        try {
            resp = await fetch(apiURL);
        } catch (error) {
            let message = `Error fetching data from roundup rest api`;
            message += `url: ${apiURL.toString()}\n`;
            throw new Error(message, { cause: error });
        }

        try {
            json = await resp.json();
        } catch (error) {
            let message = "Error parsing json from roundup rest api\n";
            message += `url: ${apiURL.toString()}\n`;
            throw new Error(message, { cause: error });
        }

        if (!resp.ok) {
            let message = `Unexpected response\n`;
            message += `url: ${apiURL.toString()}\n`;
            message += `response status: ${resp.status}\n`;
            message += `response body: ${JSON.stringify(json)}\n`;
            throw new Error(message);
        }

        collection = json.data.collection;

        const links = json.data["@links"];
        if (links?.prev?.length > 0) {
            prevPageURL = links.prev[0].uri;
        }
        if (links?.next?.length > 0) {
            nextPageURL = links.next[0].uri;
        }
        if (links?.self?.length > 0) {
            selfPageURL = new URL(links.self[0].uri);
        }

        const preview = this.popupRef.document.getElementById("popup-preview");
        if (preview) {
            accumulatorValues = preview.value.split(",");
        }

        const popupDocument = this.popupRef.document;
        const popupBody = this.popupRef.document.body;
        const pageIndex = selfPageURL.searchParams.get("@page_index");

        const oldPaginationFrag = popupDocument.getElementById("popup-pagination");
        const newPaginationFrag = this.getPaginationFragment(prevPageURL, nextPageURL, pageIndex, props.pageSize, collection.length);
        popupBody.replaceChild(newPaginationFrag, oldPaginationFrag);

        let oldTableFrag = popupDocument.getElementById("popup-tablediv");
        let newTableFrag = this.getTableFragment(props.fields, collection, accumulatorValues);
        popupBody.replaceChild(newTableFrag, oldTableFrag);
    }

    /** method when a value is selected in 
     * @param {HelpUrlProps} props
     * @param {string} value
     */
    valueSelected(props, value) {
        if (!props.formProperty) {
            return;
        }

        const input = document.getElementsByName(props.formProperty).item(0);
        input.value = value;
        this.popupRef.close();
    }

    /** method when search is performed within classhelper, here we need to update the classhelper table with search results
     * @param {URL} apiURL
     * @param {HelpUrlProps} props
     * @throws {Error} when fetching or parsing data from roundup rest api fails
     */
    async searchEvent(apiURL, props) {

        /** @type {Response} */
        let resp, json;
        /** @type {any} */
        let collection;
        /** @type {string} */
        let prevPageURL;
        /** @type {string} */
        let nextPageURL;
        /** @type {URL} */
        let selfPageURL;
        /** @type {string[]} */
        let accumulatorValues = [];

        try {
            resp = await fetch(apiURL);
        } catch (error) {
            let message = `Error fetching data from roundup rest api`;
            message += `url: ${apiURL.toString()}\n`;
            throw new Error(message, { cause: error });
        }

        try {
            json = await resp.json();
        } catch (error) {
            let message = "Error parsing json from roundup rest api\n";
            message += `url: ${apiURL.toString()}\n`;
            throw new Error(message, { cause: error });
        }

        if (!resp.ok && resp.status === 400) {
            // In the error message we will have the field name that caused the error.
            // and the value that caused the error, in a double quoted string
            // <some text> "(value)" <some text> "(key)", this regex is a capture group
            // that captures the value and key in the error message.
            let regexCaptureDoubleQuotedString = /"(.*?)"/g;
            let iterator = json.error.msg.matchAll(regexCaptureDoubleQuotedString);
            let results = Array.from(iterator);

            if (results.length == 2) {
                let value = results[0][1];
                let field = results[1][1];

                // Find the input element with the name of the key
                let input = this.popupRef.document.getElementsByName(field).item(0);
                if (input) {
                    let parent = input.parentElement;
                    parent.classList.add("search-error");
                    parent.dataset.errorValue = value;
                    parent.dataset.errorField = field;
                    // remove if there was already an error message
                    parent.getElementsByClassName("error-message").item(0)?.remove();
                    let span = document.createElement("div");
                    span.classList.add("error-message");
                    span.textContent = `Invalid value: ${value}`;
                    parent.appendChild(span);
                    return;
                }
            }
        }

        if (!resp.ok && resp.status === 403) {
            this.popupRef.alert(json.error.msg);
            return;
        }

        if (!resp.ok) {
            let message = `Unexpected response\n`;
            message += `url: ${apiURL.toString()}\n`;
            message += `response status: ${resp.status}\n`;
            message += `response body: ${JSON.stringify(json)}\n`;
            throw new Error(message);
        }

        collection = json.data.collection;

        const links = json.data["@links"];
        if (links?.prev?.length > 0) {
            prevPageURL = links.prev[0].uri;
        }
        if (links?.next?.length > 0) {
            nextPageURL = links.next[0].uri;
        }
        if (links?.self?.length > 0) {
            selfPageURL = new URL(links.self[0].uri);
        }

        const preview = this.popupRef.document.getElementById("popup-preview");
        if (preview) {
            accumulatorValues = preview.value.split(",");
        }

        const popupDocument = this.popupRef.document;
        const popupBody = this.popupRef.document.body;
        const pageIndex = selfPageURL.searchParams.get("@page_index");

        // remove any previous error messages
        let errors = Array.from(popupDocument.getElementsByClassName("search-error"));
        errors.forEach(element => {
            element.classList.remove("search-error");
            element.getElementsByClassName("error-message").item(0)?.remove();
        });

        const oldPaginationFrag = popupDocument.getElementById("popup-pagination");
        let newPaginationFrag = this.getPaginationFragment(prevPageURL, nextPageURL, pageIndex, props.pageSize, collection.length);
        popupBody.replaceChild(newPaginationFrag, oldPaginationFrag);


        let oldTableFrag = popupDocument.getElementById("popup-tablediv");
        let newTableFrag = this.getTableFragment(props.fields, collection, accumulatorValues);
        popupBody.replaceChild(newTableFrag, oldTableFrag);
    }

    /** method when an entry in classhelper table is selected
     * @param {string} value
     */
    selectionEvent(value) {
        const preview = this.popupRef.document.getElementById("popup-preview");
        if (!preview) {
            return;
        }

        if (preview.value == "" || preview.value == null) {
            preview.value = value
        } else {
            const values = preview.value.split(',');
            const exists = values.findIndex(v => v == value.toString());

            if (exists > -1) {
                values.splice(exists, 1);
                preview.value = values.join(',');
            } else {
                preview.value += ',' + value;
            }
        }
    }
}

function enableClassHelper() {
    if (document.URL.endsWith("#classhelper-wc-toggle")) {
        return;
    }

    if (DISABLE_CLASSHELPER) {
      return;
    }

    /** make api call if error then do not register*/
    // http://localhost/demo/rest

    fetch("rest")
        .then(resp => resp.json())
        .then(json => {
            if (json.error) {
                console.log(json.error);
                return;
            }
            customElements.define(CLASSHELPER_TAG_NAME, ClassHelper);
            ClassHelper.fetchTranslations()
            .catch(error => {
                console.warn("Classhelper failed in translating.")
                console.error(error);
            });
        }).catch(err => {
            console.error(err);
        });
}

enableClassHelper();

Roundup Issue Tracker: http://roundup-tracker.org/