%PDF- %PDF-
Mini Shell

Mini Shell

Direktori : /var/www/projetos/suporte.iigd.com.br.old/js/modules/Kanban/
Upload File :
Create Path :
Current File : /var/www/projetos/suporte.iigd.com.br.old/js/modules/Kanban/Kanban.js

/**
 * ---------------------------------------------------------------------
 *
 * GLPI - Gestionnaire Libre de Parc Informatique
 *
 * http://glpi-project.org
 *
 * @copyright 2015-2022 Teclib' and contributors.
 * @copyright 2003-2014 by the INDEPNET Development Team.
 * @licence   https://www.gnu.org/licenses/gpl-3.0.html
 *
 * ---------------------------------------------------------------------
 *
 * LICENSE
 *
 * This file is part of GLPI.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * ---------------------------------------------------------------------
 */

import SearchInput from "../SearchTokenizer/SearchInput.js";

/* global escapeMarkupText */
/* global sortable */
/* global glpi_toast_error */

/**
 * Kanban rights structure
 * @since 10.0.0
 */
class GLPIKanbanRights {
    constructor(rights) {
        /**
       * If true, then a button will be added to each column to allow new items to be added.
       * When an item is added, a request is made via AJAX to create the item in the DB.
       * Permissions are re-checked server-side during this request.
       * Users will still be limited by the {@link create_card_limited_columns} right both client-side and server-side.
       * @since 9.5.0
       * @since 10.0.0 Moved to new rights class
       * @type {boolean}
       */
        this.create_item = rights['create_item'] || false;

        /**
       * If true, then a button will be added to each card to allow deleting them and the underlying item directly from the kanban.
       * When a card is deleted, a request is made via AJAX to delete the item in the DB.
       * Permissions are re-checked server-side during this request.
       * @since 10.0.0
       * @type {boolean}
       */
        this.delete_item = rights['delete_item'] || false;

        /**
       * If true, then a button will be added to the add column form that lets the user create a new column.
       * For Projects as an example, it would create a new project state.
       * Permissions are re-checked server-side during this request.
       * @since 9.5.0
       * @since 10.0.0 Moved to new rights class
       * @type {boolean}
       */
        this.create_column = rights['create_column'] || false;

        /**
       * Global permission for being able to modify the Kanban state/view.
       * This includes the order of cards in the columns.
       * @since 9.5.0
       * @since 10.0.0 Moved to new rights class
       * @type {boolean}
       */
        this.modify_view = rights['modify_view'] || false;

        /**
       * Limits the columns that the user can add cards to.
       * By default, it is empty which allows cards to be added to all columns.
       * If you don't want the user to add cards to any column, {@link rights.create_item} should be false.
       * @since 9.5.0
       * @since 10.0.0 Moved to new rights class
       * @type {Array}
       */
        this.create_card_limited_columns = rights['create_card_limited_columns'] || [];

        /**
       * Global right for ordering cards.
       * @since 9.5.0
       * @since 10.0.0 Moved to new rights class
       * @type {boolean}
       */
        this.order_card = rights['order_card'] || false;
    }

    /** @see this.create_item */
    canCreateItem() {
        return this.create_item;
    }

    /** @see this.delete_item */
    canDeleteItem() {
        return this.delete_item;
    }

    /** @see this.create_column */
    canCreateColumn() {
        return this.create_column;
    }

    /** @see this.modify_view */
    canModifyView() {
        return this.modify_view;
    }

    /** @see this.order_card */
    canOrderCard() {
        return this.order_card;
    }

    /** @see this.create_card_limited_columns */
    getAllowedColumnsForNewCards() {
        return this.create_card_limited_columns;
    }
}

(function(){
    window.GLPIKanban = function() {
        /**
       * Self-reference for property access in functions.
       */
        const self = this;

        /**
       * Selector for the parent Kanban element. This is specified in PHP and passed in the GLPIKanban constructor.
       * @since 9.5.0
       * @type {string}
       */
        this.element = "";

        /**
       * The original column state when the Kanban was built or refreshed.
       * It should not be considered up to date beyond the initial build/refresh.
       * @since 9.5.0
       * @type {Array}
       */
        this.columns = {};

        /**
       * The AJAX directory.
       * @since 9.5.0
       * @type {string}
       */
        this.ajax_root = CFG_GLPI.root_doc + "/ajax/";

        /**
       * The maximum number of badges able to be shown before an overflow badge is added.
       * @since 9.5.0
       * @type {number}
       */
        this.max_team_images = 3;

        /**
       * The size in pixels for the team badges.
       * @since 9.5.0
       * @type {number}
       */
        this.team_image_size = 24;

        /**
       * The parent item for this Kanban. In the future, this may be null for personal/unrelated Kanbans. For now, it is expected to be defined.
       * @since 9.5.0
       * @type {Object|{itemtype: string, items_id: number}}
       */
        this.item = null;

        /**
       * Object of itemtypes that can be used as items in the Kanban. They should be in the format:
       * itemtype => [
       *    'name' => Localized itemtype name
       *    'fields' => [
       *       field_name   => [
       *          'placeholder' => placeholder text (optional) = blank,
       *          'type' => input type (optional) default = text,
       *          'value' => value (optional) default = blank
       *       ]
       *    ]
       * ]
       * @since 9.5.0
       * @type {Object}
       */
        this.supported_itemtypes = {};

        /**
       * User rights object
       * @type {GLPIKanbanRights}
       */
        this.rights = new GLPIKanbanRights({});

        /** @deprecated 10.0.0 Use rights.canCreateItem() instead */
        this.allow_add_item = false;
        /** @deprecated 10.0.0 Use rights.canDeleteItem() instead */
        this.allow_delete_item = false;
        /** @deprecated 10.0.0 Use rights.canCreateColumn() instead */
        this.allow_create_column = false;
        /** @deprecated 10.0.0 Use rights.canModifyView() instead */
        this.allow_modify_view = false;
        /** @deprecated 10.0.0 Use rights.getAllowedColumnsForNewCards() instead */
        this.limit_addcard_columns = [];
        /** @deprecated 10.0.0 Use rights.canOrderCard() instead */
        this.allow_order_card = false;

        /**
       * Specifies if the user's current palette is a dark theme (darker for example).
       * This will help determine the colors of the generated badges.
       * @since 9.5.0
       * @type {boolean}
       */
        this.dark_theme = false;

        /**
       * Name of the DB field used to specify columns and any extra fields needed to create the column (Ex: color).
       * For example, Projects organize items by the state of the sub-Projects and sub-Tasks.
       * Therefore, the column_field id is 'projectstates_id' with any additional fields needed being specified in extra_fields.
       * @since 9.5.0
       * @type {{id: string, extra_fields: Object}}
       */
        this.column_field = {id: '', extra_fields: {}};

        /**
       * Specifies if the Kanban's toolbar (switcher, filters, etc.) should be shown.
       * This is true by default, but may be set to false if used on a fullscreen display for example.
       * @since 9.5.0
       * @type {boolean}
       */
        this.show_toolbar = true;

        /**
       * Filters being applied to the Kanban view.
       * For now, only a simple/regex text filter is supported.
       * This can be extended in the future to support more specific filters specified per itemtype.
       * The name of internal filters like the text filter begin with an underscore.
       * @since 9.5.0
       * @type {{_text: string}}
       */
        this.filters = {
            _text: ''
        };

        this.filter_tokenizer = null;

        this.supported_filters = [];

        /**
       * The ID of the add column form.
       * @since 9.5.0
       * @type {string}
       */
        this.add_column_form = '';

        /**
       * The ID of the create column form.
       * @since 9.5.0
       * @type {string}
       */
        this.create_column_form = '';

        /**
       * Cache for images to reduce network requests and keep the same generated image between cards.
       * @since 9.5.0
       * @type {{Group: {}, User: {}, Supplier: {}, Contact: {}}}
       */
        this.team_badge_cache = {
            User: {},
            Group: {},
            Supplier: {},
            Contact: {}
        };

        /**
       * If greater than zero, this specifies the amount of time in minutes between background refreshes,
       * During a background refresh, items are added/moved/removed based on the data in the DB.
       * It does not affect items in the process of being created.
       * When sorting an item or column, the background refresh is paused to avoid a disruption or incorrect data.
       * @since 9.5.0
       * @type {number} Time in minutes between background refreshes.
       */
        this.background_refresh_interval = 0;

        /**
       * Internal refresh function
       * @since 9.5.0
       * @type {function}
       * @private
       */
        let _backgroundRefresh = null;

        /**
       * Reference for the background refresh timer
       * @type {null}
       * @private
       */
        var _backgroundRefreshTimer = null;

        /**
       * The user's state object.
       * This contains an up-to-date list of columns that should be shown, the order they are in, and if they are folded.
       * @since 9.5.0
       * @type {{
       *    is_dirty: boolean,
       *    state: {}|{order_index: {column: number, folded: boolean, cards: {Array}}}
       * }}
       * The is_dirty flag indicates if the state was changed and needs to be saved.
       */
        this.user_state = {is_dirty: false, state: {}};

        /**
       * The last time the Kanban was refreshed. This is used by the server to determine if the state needs to be sent to the client again.
       * The state will only be sent if there was a change since this time.
       * @type {?string}
       */
        this.last_refresh = null;

        /**
       * Global sorting active state.
       * @since 9.5.0
       * @type {boolean}
       */
        this.is_sorting_active = false;

        this.sort_data = undefined;

        this.mutation_observer = null;

        this.display_initials = true;

        /**
       * Parse arguments and assign them to the object's properties
       * @since 9.5.0
       * @param {Object} args Object arguments
       */
        const initParams = function(args) {
            const overridableParams = [
                'element', 'max_team_images', 'team_image_size', 'item',
                'supported_itemtypes', 'allow_add_item', 'allow_add_column', 'dark_theme', 'background_refresh_interval',
                'column_field', 'allow_modify_view', 'limit_addcard_columns', 'allow_order_card', 'allow_create_column',
                'allow_delete_item', 'supported_filters', 'display_initials'
            ];
            // Use CSS variable check for dark theme detection by default
            self.dark_theme = $('html').css('--is-dark').trim() === 'true';

            if (args.length === 1) {
                for (let i = 0; i < overridableParams.length; i++) {
                    const param = overridableParams[i];
                    if (args[0][param] !== undefined) {
                        self[param] = args[0][param];
                    }
                }
            }
            // Set rights
            if (args[0]['rights'] !== undefined) {
                self.rights = new GLPIKanbanRights(args[0]['rights']);
            } else {
            // 9.5.0 style compatibility
                self.rights = new GLPIKanbanRights({
                    create_item: self.allow_add_item,
                    delete_item: self.allow_delete_item,
                    create_column: self.allow_create_column,
                    modify_view: self.allow_modify_view,
                    create_card_limited_columns: self.limit_addcard_columns,
                    order_card: self.allow_order_card
                });
            }
            if (self.filters._text === undefined) {
                self.filters._text = '';
            }
            /**
          * @type {SearchInput}
          */
            self.filter_input = null;
        };

        const initMutationObserver = function() {
            self.mutation_observer = new MutationObserver((records) => {
                records.forEach(r => {
                    if (r.addedNodes.length > 0) {
                        if (self.is_sorting_active) {
                            const sortable_placeholders = [...r.addedNodes].filter(n => n.classList.contains('sortable-placeholder'));
                            if (sortable_placeholders.length > 0) {
                                const placeholder = $(sortable_placeholders[0]);

                                const current_column = placeholder.closest('.kanban-column').attr('id');

                                // Compute current position based on list of sortable elements without current card.
                                // Indeed, current card is still in DOM (but invisible), making placeholder index in DOM
                                // not always corresponding to its position inside list of visible elements.
                                const sortable_elements = $('#' + current_column + ' ul.kanban-body > li:not([id="' + self.sort_data.card_id + '"])');
                                const current_position = sortable_elements.index(placeholder.get(0));
                                const card = $('#' + self.sort_data.card_id);
                                card.data('current-pos', current_position);

                                if (!self.rights.canOrderCard()) {
                                    if (current_column === self.sort_data.source_column) {
                                        if (current_position !== self.sort_data.source_position) {
                                            placeholder.addClass('invalid-position');
                                        } else {
                                            placeholder.removeClass('invalid-position');
                                        }
                                    } else {
                                        if (!$(placeholder).is(':last-child')) {
                                            placeholder.addClass('invalid-position');
                                        } else {
                                            placeholder.removeClass('invalid-position');
                                        }
                                    }
                                }
                            }
                        }
                    }
                });
            });
            self.mutation_observer.observe($(self.element).get(0), {
                subtree: true,
                childList: true
            });
        };

        /**
       * Build DOM elements and defer registering event listeners for when the document is ready.
       * @since 9.5.0
      **/
        const build = function() {
            $(self.element).trigger('kanban:pre_build');
            initMutationObserver();
            if (self.show_toolbar) {
                buildToolbar();
            }
            const kanban_container = $("<div class='kanban-container'><div class='kanban-columns'></div></div>").appendTo($(self.element));

            // Dropdown for single additions
            let add_itemtype_dropdown = "<ul id='kanban-add-dropdown' class='kanban-dropdown dropdown-menu' style='display: none'>";
            Object.keys(self.supported_itemtypes).forEach(function(itemtype) {
                if (self.supported_itemtypes[itemtype]['allow_create'] !== false) {
                    add_itemtype_dropdown += "<li id='kanban-add-" + itemtype + "' class='dropdown-item'><span>" + self.supported_itemtypes[itemtype]['name'] + '</span></li>';
                }
            });
            add_itemtype_dropdown += '</ul>';
            kanban_container.append(add_itemtype_dropdown);

            // Dropdown for overflow (Column)
            let column_overflow_dropdown = "<ul id='kanban-overflow-dropdown' class='kanban-dropdown  dropdown-menu' style='display: none'>";
            let add_itemtype_bulk_dropdown = "<ul id='kanban-bulk-add-dropdown' class='dropdown-menu' style='display: none'>";
            Object.keys(self.supported_itemtypes).forEach(function(itemtype) {
                if (self.supported_itemtypes[itemtype]['allow_create'] !== false) {
                    add_itemtype_bulk_dropdown += "<li id='kanban-bulk-add-" + itemtype + "' class='dropdown-item'><span>" + self.supported_itemtypes[itemtype]['name'] + '</span></li>';
                }
            });
            add_itemtype_bulk_dropdown += '</ul>';
            const add_itemtype_bulk_link = '<a href="#">' + '<i class="fa-fw fas fa-list"></i>' + __('Bulk add') + '</a>';
            column_overflow_dropdown += '<li class="dropdown-trigger dropdown-item">' + add_itemtype_bulk_link + add_itemtype_bulk_dropdown + '</li>';
            if (self.rights.canModifyView()) {
                column_overflow_dropdown += "<li class='kanban-remove dropdown-item' data-forbid-protected='true'><span>"  + '<i class="fa-fw ti ti-trash"></i>' + __('Delete') + "</span></li>";
            }
            column_overflow_dropdown += '</ul>';
            kanban_container.append(column_overflow_dropdown);

            // Dropdown for overflow (Card)

            let card_overflow_dropdown = "<ul id='kanban-item-overflow-dropdown' class='kanban-dropdown dropdown-menu' style='display: none'>";
            card_overflow_dropdown += `
            <li class='kanban-item-goto dropdown-item'>
               <a href="#"><i class="fa-fw fas fa-share"></i>${__('Go to')}</a>
            </li>`;
            if (self.rights.canDeleteItem()) {
                card_overflow_dropdown += `
                <li class='kanban-item-remove dropdown-item'>
                   <span>
                      <i class="fa-fw ti ti-trash"></i>${__('Delete')}
                   </span>
                </li>`;
            }
            card_overflow_dropdown += '</ul>';
            kanban_container.append(card_overflow_dropdown);

            $('#kanban-overflow-dropdown li.dropdown-trigger > a').on("click", function(e) {
                $(this).parent().toggleClass('active');
                $(this).parent().find('ul').toggle();
                e.stopPropagation();
                e.preventDefault();
            });

            $('#kanban-item-overflow-dropdown li.dropdown-trigger > a').on("click", function(e) {
                $(this).parent().toggleClass('active');
                $(this).parent().find('ul').toggle();
                e.stopPropagation();
                e.preventDefault();
            });

            const on_refresh = function() {
                if (Object.keys(self.user_state.state).length === 0) {
                    // Save new state since none was stored for the user
                    saveState(true, true);
                }
            };
            self.refresh(on_refresh, null, null, true);

            if (self.rights.canModifyView()) {
                buildAddColumnForm();
                if (self.rights.canCreateColumn()) {
                    buildCreateColumnForm();
                }
            }
            $(self.element).trigger('kanban:post_build');
        };

        const buildToolbar = function() {
            $(self.element).trigger('kanban:pre_build_toolbar');
            let toolbar = $("<div class='kanban-toolbar card flex-column flex-md-row'></div>").appendTo(self.element);
            $("<select name='kanban-board-switcher'></select>").appendTo(toolbar);
            let filter_input = $(`<input name='filter' class='form-control ms-1' type='text' placeholder="${__('Search or filter results')}" autocomplete="off"/>`).appendTo(toolbar);
            if (self.rights.canModifyView()) {
                let add_column = "<button class='kanban-add-column btn btn-outline-secondary ms-1'>" + __('Add column') + "</button>";
                toolbar.append(add_column);
            }

            self.filter_input = new SearchInput(filter_input, {
                allowed_tags: self.supported_filters,
                on_result_change: (e, result) => {
                    self.filters = {
                        _text: ''
                    };
                    self.filters._text = result.getFullPhrase();
                    result.getTaggedTerms().forEach(t => self.filters[t.tag] = {
                        term: t.term || '',
                        exclusion: t.exclusion || false,
                        prefix: t.prefix
                    });
                    self.filter();
                },
                tokenizer_options: {
                    custom_prefixes: {
                        '#': { // Regex prefix
                            label: __('Regex'),
                            token_color: '#00800080'
                        }
                    }
                }
            });
            self.refreshSearchTokenizer();
            self.filter();

            $(self.element).trigger('kanban:post_build_toolbar');
        };

        const getColumnElementFromID = function(column_id) {
            return '#column-' + self.column_field.id + '-' + column_id;
        };

        const getColumnIDFromElement = function(column_el) {
            let element_id = [column_el];
            if (typeof column_el !== 'string') {
                element_id = $(column_el).prop('id').split('-');
            } else {
                element_id = column_el.split('-');
            }
            return element_id[element_id.length - 1];
        };

        const preserveNewItemForms = function() {
            self.temp_forms = {};
            let columns = $(self.element + " .kanban-column");
            $.each(columns, function(i, column) {
                let forms = $(column).find('.kanban-add-form');
                if (forms.length > 0) {
                    self.temp_forms[column.id] = [];
                    $.each(forms, function(i2, form) {
                        // Copy event handlers for element and child elements
                        // Otherwise, the Add button will act like a normal submit button (not wanted)
                        self.temp_forms[column.id].push($(form).clone(true, true));
                    });
                }
            });
        };

        const restoreNewItemForms = function() {
            if (self.temp_forms !== undefined && Object.keys(self.temp_forms).length > 0) {
                $.each(self.temp_forms, function(column_id, forms) {
                    let column = $('#' + column_id);
                    if (column.length > 0) {
                        let column_body = column.find('.kanban-body').first();
                        $.each(forms, function(i, form) {
                            $(form).appendTo(column_body);
                        });
                    }
                });
                self.temp_forms = {};
            }
        };

        const preserveScrolls = function() {
            self.temp_kanban_scroll = {
                left: $(self.element + ' .kanban-container').scrollLeft(),
                top: $(self.element + ' .kanban-container').scrollTop()
            };
            self.temp_column_scrolls = {};
            let columns = $(self.element + " .kanban-column");
            $.each(columns, function(i, column) {
                let column_body = $(column).find('.kanban-body');
                if (column_body.scrollTop() !== 0) {
                    self.temp_column_scrolls[column.id] = column_body.scrollTop();
                }
            });
        };

        const restoreScrolls = function() {
            if (self.temp_kanban_scroll !== null) {
                $(self.element + ' .kanban-container').scrollLeft(self.temp_kanban_scroll.left);
                $(self.element + ' .kanban-container').scrollTop(self.temp_kanban_scroll.top);
            }
            if (self.temp_column_scrolls !== null) {
                $.each(self.temp_column_scrolls, function(column_id, scroll) {
                    $('#' + column_id + ' .kanban-body').scrollTop(scroll);
                });
            }
            self.temp_kanban_scroll = {};
            self.temp_column_scrolls = {};
        };

        /**
       * Clear all columns from the Kanban.
       * Should be used in conjunction with {@link fillColumns()} to refresh the Kanban.
       * @since 9.5.0
       */
        const clearColumns = function() {
            preserveScrolls();
            preserveNewItemForms();
            $(self.element + " .kanban-column").remove();
        };

        /**
       * Add all columns to the kanban. This does not clear the existing columns first.
       *    If you are refreshing the Kanban, you should call {@link clearColumns()} first.
       * @since 9.5.0
       * @param {Object} columns_container JQuery Object of columns container. Not required.
       *    If not specified, a new object will be created to reference this Kanban's columns container.
       */
        const fillColumns = function(columns_container) {
            if (columns_container === undefined) {
                columns_container = $(self.element + " .kanban-container .kanban-columns").first();
            }

            let already_processed = [];
            $.each(self.user_state.state, function(position, column) {
                if (column['visible'] !== false && column !== 'false') {
                    if (self.columns[column['column']] !== undefined) {
                        appendColumn(column['column'], self.columns[column['column']], columns_container);
                    }
                }
                already_processed.push(column['column']);
            });
            $.each(self.columns, function(column_id, column) {
                if (!already_processed.includes(column_id)) {
                    if (column['id'] === undefined) {
                        appendColumn(column_id, column, columns_container);
                    }
                }
            });
            restoreNewItemForms();
            restoreScrolls();
        };

        /**
       * Add all event listeners. At this point, all elements should have been added to the DOM.
       * @since 9.5.0
       */
        const registerEventListeners = function() {
            const add_dropdown = $('#kanban-add-dropdown');
            const column_overflow_dropdown = $('#kanban-overflow-dropdown');
            const card_overflow_dropdown = $('#kanban-item-overflow-dropdown');

            refreshSortables();

            if (Object.keys(self.supported_itemtypes).length > 0) {
                $(self.element + ' .kanban-container').on('click', '.kanban-add', function(e) {
                    const button = $(e.target);
                    //Keep menu open if clicking on another add button
                    const force_stay_visible = $(add_dropdown.data('trigger-button')).prop('id') !== button.prop('id');
                    add_dropdown.css({
                        position: 'fixed',
                        left: button.offset().left,
                        top: button.offset().top + button.outerHeight(true),
                        display: (add_dropdown.css('display') === 'none' || force_stay_visible) ? 'inline' : 'none'
                    });
                    add_dropdown.data('trigger-button', button);
                });
            }
            $(window).on('click', function(e) {
                if (!$(e.target).hasClass('kanban-add')) {
                    add_dropdown.css({
                        display: 'none'
                    });
                }
                if (self.rights.canModifyView()) {
                    if (!$.contains($(self.add_column_form)[0], e.target)) {
                        $(self.add_column_form).css({
                            display: 'none'
                        });
                    }
                    if (self.rights.canCreateColumn()) {
                        if (!$.contains($(self.create_column_form)[0], e.target) && !$.contains($(self.add_column_form)[0], e.target)) {
                            $(self.create_column_form).css({
                                display: 'none'
                            });
                        }
                    }
                }
            });

            if (Object.keys(self.supported_itemtypes).length > 0) {
                $(self.element + ' .kanban-container').on('click', '.kanban-column-overflow-actions', function(e) {
                    const button = $(e.target);
                    //Keep menu open if clicking on another add button
                    const force_stay_visible = $(column_overflow_dropdown.data('trigger-button')).prop('id') !== button.prop('id');
                    column_overflow_dropdown.css({
                        position: 'fixed',
                        left: button.offset().left,
                        top: button.offset().top + button.outerHeight(true),
                        display: (column_overflow_dropdown.css('display') === 'none' || force_stay_visible) ? 'inline' : 'none'
                    });
                    // Hide sub-menus by default when opening the overflow menu
                    column_overflow_dropdown.find('ul').css({
                        display: 'none'
                    });
                    column_overflow_dropdown.find('li').removeClass('active');
                    // If this is a protected column, hide any items with data-forbid-protected='true'. Otherwise show them.
                    const column = $(e.target.closest('.kanban-column'));
                    if (column.hasClass('kanban-protected')) {
                        column_overflow_dropdown.find('li[data-forbid-protected="true"]').hide();
                    } else {
                        column_overflow_dropdown.find('li[data-forbid-protected="true"]').show();
                    }
                    column_overflow_dropdown.data('trigger-button', button);
                });
            }
            $(self.element + ' .kanban-container').on('click', '.kanban-item-overflow-actions', function(e) {
                const button = $(e.target);
                //Keep menu open if clicking on another add button
                const force_stay_visible = $(card_overflow_dropdown.data('trigger-button')).prop('id') !== button.prop('id');
                card_overflow_dropdown.css({
                    position: 'fixed',
                    left: button.offset().left,
                    top: button.offset().top + button.outerHeight(true),
                    display: (card_overflow_dropdown.css('display') === 'none' || force_stay_visible) ? 'inline' : 'none'
                });
                // Hide sub-menus by default when opening the overflow menu
                card_overflow_dropdown.find('ul').css({
                    display: 'none'
                });
                card_overflow_dropdown.find('li').removeClass('active');
                card_overflow_dropdown.data('trigger-button', button);
                const card = $(button.closest('.kanban-item'));

                const form_link = card.data('form_link');
                $(card_overflow_dropdown.find('.kanban-item-goto a')).attr('href', form_link);

                let delete_action = $(card_overflow_dropdown.find('.kanban-item-remove'));
                if (card.hasClass('deleted')) {
                    delete_action.html('<span><i class="ti ti-trash"></i>'+__('Purge')+'</span>');
                } else {
                    delete_action.html('<span><i class="ti ti-trash"></i>'+__('Delete')+'</span>');
                }
            });

            $(window).on('click', function(e) {
                if (!$(e.target).hasClass('kanban-column-overflow-actions')) {
                    column_overflow_dropdown.css({
                        display: 'none'
                    });
                }
                if (!$(e.target).hasClass('kanban-item-overflow-actions')) {
                    card_overflow_dropdown.css({
                        display: 'none'
                    });
                }
                if (self.rights.canModifyView()) {
                    if (!$.contains($(self.add_column_form)[0], e.target)) {
                        $(self.add_column_form).css({
                            display: 'none'
                        });
                    }
                    if (self.rights.canCreateColumn()) {
                        if (!$.contains($(self.create_column_form)[0], e.target) && !$.contains($(self.add_column_form)[0], e.target)) {
                            $(self.create_column_form).css({
                                display: 'none'
                            });
                        }
                    }
                }
            });

            $(self.element + ' .kanban-container').on('click', '.kanban-remove', function(e) {
            // Get root dropdown, then the button that triggered it, and finally the column that the button is in
                const column = $(e.target.closest('.kanban-dropdown')).data('trigger-button').closest('.kanban-column');
                // Hide that column
                hideColumn(getColumnIDFromElement(column));
            });
            $(self.element).on('click', '.item-details-panel .kanban-item-edit-team', (e) => {
                self.showTeamModal($(e.target).closest('.item-details-panel').data('card'));
            });
            $(self.element + ' .kanban-container').on('click', '.kanban-item-remove', function(e) {
            // Get root dropdown, then the button that triggered it, and finally the card that the button is in
                const card = $(e.target.closest('.kanban-dropdown')).data('trigger-button').closest('.kanban-item').prop('id');
                // Try to delete that card item
                deleteCard(card, undefined, undefined);
            });
            $(self.element + ' .kanban-container').on('click', '.kanban-collapse-column', function(e) {
                self.toggleCollapseColumn(e.target.closest('.kanban-column'));
            });
            $(self.element).on('click', '.kanban-add-column', function() {
                refreshAddColumnForm();
            });
            $(self.add_column_form).on('input', "input[name='column-name-filter']", function() {
                const filter_input = $(this);
                $(self.add_column_form + ' li').hide();
                $(self.add_column_form + ' li').filter(function() {
                    return $(this).text().toLowerCase().includes(filter_input.val().toLowerCase());
                }).show();
            });
            $(self.add_column_form).on('change', "input[type='checkbox']", function() {
                const column_id = $(this).parent().data('list-id');
                if (column_id !== undefined) {
                    if ($(this).is(':checked')) {
                        showColumn(column_id);
                    } else {
                        hideColumn(column_id);
                    }
                }
            });
            $(self.add_column_form).on('submit', 'form', function(e) {
                e.preventDefault();
            });
            $(self.add_column_form).on('click', '.kanban-create-column', function() {
                const toolbar = $(self.element + ' .kanban-toolbar');
                $(self.add_column_form).css({
                    display: 'none'
                });
                $(self.create_column_form).css({
                    display: 'block',
                    position: 'fixed',
                    left: toolbar.offset().left + toolbar.outerWidth(true) - $(self.create_column_form).outerWidth(true),
                    top: toolbar.offset().top + toolbar.outerHeight(true)
                });
            });
            $(self.create_column_form).on('submit', 'form', function(e) {
                e.preventDefault();

                const toolbar = $(self.element + ' .kanban-toolbar');

                $(self.create_column_form).css({
                    display: 'none'
                });
                const name = $(self.create_column_form + " input[name='name']").val();
                $(self.create_column_form + " input[name='name']").val("");
                const color = $(self.create_column_form + " input[name='color']").val();
                createColumn(name, {color: color}, function() {
                    // Refresh add column list
                    refreshAddColumnForm();
                    $(self.add_column_form).css({
                        display: 'block',
                        position: 'fixed',
                        left: toolbar.offset().left + toolbar.outerWidth(true) - $(self.add_column_form).outerWidth(true),
                        top: toolbar.offset().top + toolbar.outerHeight(true)
                    });
                });
            });
            $('#kanban-add-dropdown li').on('click', function(e) {
                e.preventDefault();
                const selection = $(this).closest('li');
                // The add dropdown is a single-level dropdown, so the parent is the ul element
                const dropdown = selection.parent();
                // Get the button that triggered the dropdown and then get the column that it is a part of
                // This is because the dropdown exists outside all columns and is not recreated each time it is opened
                const column = $($(dropdown.data('trigger-button')).closest('.kanban-column'));
                // kanban-add-ITEMTYPE (We want the ITEMTYPE token at position 2)
                const itemtype = selection.prop('id').split('-')[2];
                self.clearAddItemForms(column);
                self.showAddItemForm(column, itemtype);
                delayRefresh();
            });
            $('#kanban-bulk-add-dropdown li').on('click', function(e) {
                e.preventDefault();
                const selection = $(this).closest('li');
                // Traverse all the way up to the top-level overflow dropdown
                const dropdown = selection.closest('.kanban-dropdown');
                // Get the button that triggered the dropdown and then get the column that it is a part of
                // This is because the dropdown exists outside all columns and is not recreated each time it is opened
                const column = $($(dropdown.data('trigger-button')).closest('.kanban-column'));
                // kanban-bulk-add-ITEMTYPE (We want the ITEMTYPE token at position 3)
                const itemtype = selection.prop('id').split('-')[3];

                // Force-close the full dropdown
                dropdown.css({'display': 'none'});

                self.clearAddItemForms(column);
                self.showBulkAddItemForm(column, itemtype);
                delayRefresh();
            });
            const switcher = $("select[name='kanban-board-switcher']").first();
            $(self.element + ' .kanban-toolbar').on('select2:select', switcher, function(e) {
                const items_id = e.params.data.id;
                $.ajax({
                    type: "GET",
                    url: (self.ajax_root + "kanban.php"),
                    data: {
                        action: "get_url",
                        itemtype: self.item.itemtype,
                        items_id: items_id
                    },
                    success: function(url) {
                        window.location = url;
                    }
                });
            });

            $(self.element).on('input', '.kanban-add-form input, .kanban-add-form textarea', function() {
                delayRefresh();
            });

            if (!self.rights.canOrderCard()) {
                $(self.element).on(
                    'mouseenter',
                    '.kanban-column',
                    function () {
                        if (self.is_sorting_active) {
                            return; // Do not change readonly states if user is sorting elements
                        }
                        // If user cannot order cards, make items temporarily readonly except for current column.
                        $(this).find('.kanban-body > li').removeClass('temporarily-readonly');
                        $(this).siblings().find('.kanban-body > li').addClass('temporarily-readonly');
                    }
                );
                $(self.element).on(
                    'mouseleave',
                    '.kanban-column',
                    function () {
                        if (self.is_sorting_active) {
                            return; // Do not change readonly states if user is sorting elements
                        }
                        $(self.element).find('.kanban-body > li').removeClass('temporarily-readonly');
                    }
                );
            }

            $(self.element + ' .kanban-container').on('submit', '.kanban-add-form:not(.kanban-bulk-add-form)', function(e) {
                e.preventDefault();
                const form = $(e.target);
                const data = {
                    inputs: form.serialize(),
                    itemtype: form.prop('id').split('_')[2],
                    action: 'add_item'
                };
                const itemtype = form.attr('data-itemtype');
                const column_el_id = form.closest('.kanban-column').attr('id');

                $.ajax({
                    method: 'POST',
                    url: (self.ajax_root + "kanban.php"),
                    data: data
                }).done(function() {
                    // Close the form
                    form.remove();
                    self.refresh(undefined, undefined, () => {
                        // Re-open form
                        self.showAddItemForm($(`#${column_el_id}`), itemtype);
                    });
                });
            });

            $(self.element + ' .kanban-container').on('click', '.kanban-item .kanban-item-title', function(e) {
                e.preventDefault();
                const card = $(e.target).closest('.kanban-item');
                self.showCardPanel(card);
            });
        };

        const showModal = (content, data) => {
            const modal = $('#kanban-modal');
            modal.removeData();
            modal.data(data);
            modal.find('.modal-body').html(content);
            modal.modal('show');
        };

        const hideModal = () => {
            $('#kanban-modal').modal('hide');
        };

        /**
       * (Re-)Create the list of columns that can be shown/hidden.
       * This involves fetching the list of valid columns from the server.
       * @since 9.5.0
       */
        const refreshAddColumnForm = function() {
            let columns_used = [];
            $(self.element + ' .kanban-columns .kanban-column').each(function() {
                const column_id = this.id.split('-');
                columns_used.push(column_id[column_id.length - 1]);
            });
            const column_dialog = $(self.add_column_form);
            const toolbar = $(self.element + ' .kanban-toolbar');
            $.ajax({
                method: 'GET',
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "list_columns",
                    itemtype: self.item.itemtype,
                    column_field: self.column_field.id
                }
            }).done(function(data) {
                const form_content = $(self.add_column_form + " .kanban-item-content");
                form_content.empty();
                form_content.append("<input type='text' class='form-control' name='column-name-filter' placeholder='" + __('Search') + "'/>");
                let list = "<ul class='kanban-columns-list'>";
                $.each(data, function(column_id, column) {
                    let list_item = "<li data-list-id='"+column_id+"'>";
                    if (columns_used.includes(column_id)) {
                        list_item += "<input type='checkbox' checked='true' class='form-check-input' />";
                    } else {
                        list_item += "<input type='checkbox' class='form-check-input' />";
                    }
                    if (typeof column['color_class'] !== "undefined") {
                        list_item += "<span class='kanban-color-preview "+column['color_class']+"'></span>";
                    } else {
                        list_item += "<span class='kanban-color-preview' style='background-color: "+column['header_color']+"'></span>";
                    }
                    list_item += column['name'] + "</li>";
                    list += list_item;
                });
                list += "</ul>";
                form_content.append(list);
                form_content.append();

                column_dialog.css({
                    display: 'block',
                    position: 'fixed',
                    left: toolbar.offset().left + toolbar.outerWidth(true) - column_dialog.outerWidth(true),
                    top: toolbar.offset().top + toolbar.outerHeight(true)
                });
            });
        };

        /**
       * (Re-)Initialize JQuery sortable for all items and columns.
       * This should be called every time a new column or item is added to the board.
       * @since 9.5.0
       */
        const refreshSortables = function() {
            $(self.element).trigger('kanban:refresh_sortables');
            // Make sure all items in the columns can be sorted
            const bodies = $(self.element + ' .kanban-body');
            $.each(bodies, function(b) {
                const body = $(b);
                if (body.data('sortable')) {
                    sortable(b, 'destroy');
                }
            });

            sortable(self.element + ' .kanban-body', {
                acceptFrom: '.kanban-body',
                items: '.kanban-item:not(.readonly):not(.temporarily-readonly)',
            });

            $(self.element + ' .kanban-body').off('sortstart');
            $(self.element + ' .kanban-body').on('sortstart', (e) => {
                self.is_sorting_active = true;

                const card = $(e.detail.item);
                // Track the column and position the card was picked up from
                const current_column = card.closest('.kanban-column').attr('id');
                card.data('source-col', current_column);
                card.data('source-pos', e.detail.origin.index);

                self.sort_data = {
                    card_id: card.attr('id'),
                    source_column: current_column,
                    source_position: e.detail.origin.index
                };
            });

            $(self.element + ' .kanban-body').off('sortupdate');
            $(self.element + ' .kanban-body').on('sortupdate', function(e) {
                const card = e.detail.item;
                if (this === $(card).parent()[0]) {
                    return self.onKanbanCardSort(e, this);
                }
            });

            $(self.element + ' .kanban-body').off('sortstop');
            $(self.element + ' .kanban-body').on('sortstop', (e) => {
                self.is_sorting_active = false;
                $(e.detail.item).closest('.kanban-column').trigger('mouseenter'); // force readonly states refresh
            });

            if (self.rights.canModifyView()) {
            // Enable column sorting
                sortable(self.element + ' .kanban-columns', {
                    acceptFrom: self.element + ' .kanban-columns',
                    appendTo: '.kanban-container',
                    items: '.kanban-column:not(.kanban-protected)',
                    handle: '.kanban-column-header',
                    orientation: 'horizontal',
                });
                $(self.element + ' .kanban-columns .kanban-column:not(.kanban-protected) .kanban-column-header').addClass('grab');
            }

            $(self.element + ' .kanban-columns').off('sortstop');
            $(self.element + ' .kanban-columns').on('sortstop', (e) => {
                const column = e.detail.item;
                updateColumnPosition(getColumnIDFromElement(column), $(column).index());
            });
        };

        /**
       * Construct and return the toolbar HTML for a specified column.
       * @since 9.5.0
       * @param {Object} column Column object that this toolbar will be made for.
       * @returns {string} HTML coded for the toolbar.
       */
        const getColumnToolbarElement = function(column) {
            let toolbar_el = "<span class='kanban-column-toolbar'>";
            const column_id = parseInt(getColumnIDFromElement(column['id']));
            if (self.rights.canCreateItem() && (self.rights.getAllowedColumnsForNewCards().length === 0 || self.rights.getAllowedColumnsForNewCards().includes(column_id))) {
                toolbar_el += "<i id='kanban_add_" + column['id'] + "' class='kanban-add btn btn-sm btn-ghost-secondary fas fa-plus' title='" + __('Add') + "'></i>";
                toolbar_el += "<i id='kanban_column_overflow_actions_" + column['id'] +"' class='kanban-column-overflow-actions btn btn-sm btn-ghost-secondary fas fa-ellipsis-h' title='" + __('More') + "'></i>";
            }
            toolbar_el += "</span>";
            return toolbar_el;
        };

        /**
       * Hide all columns that don't have a card in them.
       * @since 9.5.0
      **/
        this.hideEmpty = function() {
            const bodies = $(".kanban-body");
            bodies.each(function(index, item) {
                if (item.childElementCount === 0) {
                    item.parentElement.style.display = "none";
                }
            });
        };

        /**
       * Show all columns that don't have a card in them.
       * @since 9.5.0
      **/
        this.showEmpty = function() {
            const columns = $(".kanban-column");
            columns.each(function(index, item) {
                item.style.display = "block";
            });
        };

        /**
       * Callback function for when a kanban item is moved.
       * @since 9.5.0
       * @param {Object}  e      Event.
       * @param {Element} sortable Sortable object
       * @returns {Boolean}       Returns false if the sort was cancelled.
      **/
        this.onKanbanCardSort = function(e, sortable) {
            const target = sortable.parentElement;
            const source = $(e.detail.origin.container);
            const card = $(e.detail.item);
            const el_params = card.attr('id').split('-');
            const target_params = $(target).attr('id').split('-');
            const column_id = target_params[target_params.length - 1];

            if (el_params.length === 2 && source !== null && !(!self.rights.canOrderCard() && source.length === 0)) {
                $.ajax({
                    type: "POST",
                    url: (self.ajax_root + "kanban.php"),
                    data: {
                        action: "update",
                        itemtype: el_params[0],
                        items_id: el_params[1],
                        column_field: self.column_field.id,
                        column_value: column_id
                    },
                    error: function() {
                        window.sortable(sortable, 'cancel');
                        return false;
                    },
                    success: function() {
                        let pos = card.data('current-pos');
                        if (!self.rights.canOrderCard()) {
                            card.appendTo($(target).find('.kanban-body').first());
                            pos = card.index();
                        }
                        // Update counters. Always pass the column element instead of the kanban body (card container)
                        self.updateColumnCount($(source).closest('.kanban-column'));
                        self.updateColumnCount($(target).closest('.kanban-column'));
                        card.removeData('source-col');
                        updateCardPosition(card.attr('id'), target.id, pos);
                        return true;
                    }
                });
            } else {
                window.sortable(sortable, 'cancel');
                return false;
            }
        };

        /**
       * Send the new card position to the server.
       * @since 9.5.0
       * @param {string} card The ID of the card being moved.
       * @param {string|number} column The ID or element of the column the card resides in.
       * @param {number} position The position in the column that the card is at.
       * @param {function} error Callback function called when the server reports an error.
       * @param {function} success Callback function called when the server processes the request successfully.
       */
        const updateCardPosition = function(card, column, position, error, success) {
            if (typeof column === 'string' && column.lastIndexOf('column', 0) === 0) {
                column = getColumnIDFromElement(column);
            }
            $.ajax({
                type: "POST",
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "move_item",
                    card: card,
                    column: column,
                    position: position,
                    kanban: self.item
                },
                error: function() {
                    if (error) {
                        error();
                    }
                },
                success: function() {
                    if (success) {
                        success();
                        $('#'+card).trigger('kanban:card_move');
                    }
                }
            });
        };

        /**
       * Delete a card
       * @since 10.0.0
       * @param {string} card The ID of the card being deleted.
       * @param {function} error Callback function called when the server reports an error.
       * @param {function} success Callback function called when the server processes the request successfully.
       */
        const deleteCard = function(card, error, success) {
            const [itemtype, items_id] = card.split('-', 2);
            const card_obj = $('#'+card);
            const force = card_obj.hasClass('deleted');
            $.ajax({
                type: "POST",
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "delete_item",
                    itemtype: itemtype,
                    items_id: items_id,
                    force: force ? 1 : 0
                },
                error: function() {
                    if (error) {
                        error();
                    }
                },
                success: function() {
                    const column = card_obj.closest('.kanban-column');
                    card_obj.remove();
                    self.updateColumnCount(column);
                    if (success) {
                        success();
                        $('#'+card).trigger('kanban:card_delete');
                    }
                }
            });
        };

        /**
       * Show the column and notify the server of the change.
       * @since 9.5.0
       * @param {number} column The ID of the column.
       */
        const showColumn = function(column) {
            $.ajax({
                type: "POST",
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "show_column",
                    column: column,
                    kanban: self.item
                },
                complete: function() {
                    $.each(self.user_state.state, function(i, c) {
                        if (parseInt(c['column']) === parseInt(column)) {
                            self.user_state.state[i]['visible'] = true;
                            return false;
                        }
                    });
                    loadColumn(column, false, true);
                    $(self.element + " .kanban-add-column-form li[data-list-id='" + column + "']").prop('checked', true);
                }
            });
        };

        /**
       * Hide the column and notify the server of the change.
       * @since 9.5.0
       * @param {number} column The ID of the column.
       */
        const hideColumn = function(column) {
            $.ajax({
                type: "POST",
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "hide_column",
                    column: column,
                    kanban: self.item
                },
                complete: function() {
                    $(getColumnElementFromID(column)).remove();
                    $.each(self.user_state.state, function(i, c) {
                        if (parseInt(c['column']) === parseInt(column)) {
                            self.user_state.state[i]['visible'] = false;
                            return false;
                        }
                    });
                    $(self.element + " .kanban-add-column-form li[data-list-id='" + column + "']").prop('checked', false);
                }
            });
        };

        /**
       * Notify the server that the column's position has changed.
       * @since 9.5.0
       * @param {number} column The ID of the column.
       * @param {number} position The position of the column.
       */
        const updateColumnPosition = function(column, position) {
            $.ajax({
                type: "POST",
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "move_column",
                    column: column,
                    position: position,
                    kanban: self.item
                }
            });
        };

        /**
       * Get or create team member badge
       * @since 9.5.0
       * @param {array} teammember
       * @returns {string} HTML image or icon
       * @see generateUserBadge()
       * @see generateOtherBadge()
      **/
        const getTeamBadge = function(teammember) {
            const itemtype = teammember["itemtype"];
            const items_id = teammember["id"];

            if (self.team_badge_cache[itemtype] === undefined ||
                 self.team_badge_cache[itemtype][items_id] === undefined) {
                if (itemtype === 'User') {
                    let user_img = null;
                    $.ajax({
                        url: (self.ajax_root + "getUserPicture.php"),
                        async: false,
                        data: {
                            users_id: [items_id],
                            size: self.team_image_size,
                        }
                    }).done(function(data) {
                        if (data[items_id] !== undefined) {
                            user_img = data[items_id];
                        } else {
                            user_img = null;
                        }
                    });

                    if (user_img) {
                        self.team_badge_cache[itemtype][items_id] = "<span>" + user_img + "</span>";
                    } else {
                        self.team_badge_cache[itemtype][items_id] = generateUserBadge(teammember);
                    }
                } else {
                    switch (itemtype) {
                        case 'Group':
                            self.team_badge_cache[itemtype][items_id] = generateOtherBadge(teammember, 'fa-users');
                            break;
                        case 'Supplier':
                            self.team_badge_cache[itemtype][items_id] = generateOtherBadge(teammember, 'fa-briefcase');
                            break;
                        case 'Contact':
                            self.team_badge_cache[itemtype][items_id] = generateOtherBadge(teammember, 'fa-user');
                            break;
                        default:
                            self.team_badge_cache[itemtype][items_id] = generateOtherBadge(teammember, 'fa-user');
                    }
                }
            }
            return self.team_badge_cache[itemtype][items_id];
        };

        /**
       * Attempt to get and cache user badges in a single AJAX request to reduce time wasted when using multiple requests.
       * Most time spent on the request is latency, so it takes about the same amount of time for 1 or 50 users.
       * If no image is returned from the server, a badge is generated based on the user's initials.
       * @since 9.5.0
       * @param {Object} options Object of options for this function. Supports:
       *    trim_cache - boolean indicating if unused user images should be removed from the cache.
       *       This is useful for refresh scenarios.
       * @see generateUserBadge()
      **/
        const preloadBadgeCache = function(options) {
            let users = [];
            $.each(self.columns, function(column_id, column) {
                if (column['items'] !== undefined) {
                    $.each(column['items'], function(card_id, card) {
                        if (card["_team"] !== undefined) {
                            Object.values(card["_team"]).slice(0, self.max_team_images).forEach(function(teammember) {
                                if (teammember['itemtype'] === 'User') {
                                    if (self.team_badge_cache['User'][teammember['id']] === undefined) {
                                        users[teammember['id']] = teammember;
                                    }
                                }
                            });
                        }
                    });
                }
            });
            if (users.length === 0) {
                return;
            }
            $.ajax({
                url: (self.ajax_root + "getUserPicture.php"),
                async: false,
                data: {
                    users_id: Object.keys(users),
                    size: self.team_image_size
                }
            }).done(function(data) {
                Object.keys(users).forEach(function(user_id) {
                    const teammember = users[user_id];
                    if (data[user_id] !== undefined) {
                        self.team_badge_cache['User'][user_id] = "<span>" + data[user_id] + "</span>";
                    } else {
                        self.team_badge_cache['User'][user_id] = generateUserBadge(teammember);
                    }
                });
                if (options !== undefined && options['trim_cache'] !== undefined) {
                    let cached_colors = JSON.parse(window.sessionStorage.getItem('badge_colors'));
                    Object.keys(self.team_badge_cache['User']).forEach(function(user_id) {
                        if (users[user_id] === undefined) {
                            delete self.team_badge_cache['User'][user_id];
                            delete cached_colors['User'][user_id];
                        }
                    });
                    window.sessionStorage.setItem('badge_colors', JSON.stringify(cached_colors));
                }
            });
        };

        /**
       * Convert the given H, S, L values into a color hex code (with prepended hash symbol).
       * @param {number} h Hue
       * @param {number} s Saturation
       * @param {number} l Lightness
       * @returns {string} Hex code color value
       */
        const hslToHexColor = function(h, s, l) {
            let r, g, b;

            if (s === 0) {
                r = g = b = l;
            } else {
                const hue2rgb = function hue2rgb(p, q, t){
                    if (t < 0)
                        t += 1;
                    if (t > 1)
                        t -= 1;
                    if (t < 1/6)
                        return p + (q - p) * 6 * t;
                    if (t < 1/2)
                        return q;
                    if (t < 2/3)
                        return p + (q - p) * (2/3 - t) * 6;
                    return p;
                };

                const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
                const p = 2 * l - q;
                r = hue2rgb(p, q, h + 1/3);
                g = hue2rgb(p, q, h);
                b = hue2rgb(p, q, h - 1/3);
            }

            r = ('0' + (r * 255).toString(16)).substr(-2);
            g = ('0' + (g * 255).toString(16)).substr(-2);
            b = ('0' + (b * 255).toString(16)).substr(-2);
            return '#' + r + g + b;
        };

        /**
       * Compute a new badge color or retrieve the cached color from session storage.
       * @since 9.5.0
       * @param {Object} teammember The teammember this badge is for.
       * @returns {string} Hex code color value
       */
        const getBadgeColor = function(teammember) {
            let cached_colors = JSON.parse(window.sessionStorage.getItem('badge_colors'));
            const itemtype = teammember['itemtype'];
            const baseColor = Math.random();
            const lightness = (Math.random() * 10) + (self.dark_theme ? 25 : 70);
            //var bg_color = "hsl(" + baseColor + ", 100%," + lightness + "%,1)";
            let bg_color = hslToHexColor(baseColor, 1, lightness / 100);

            if (cached_colors !== null && cached_colors[itemtype] !== null && cached_colors[itemtype][teammember['id']]) {
                bg_color = cached_colors[itemtype][teammember['id']];
            } else {
                if (cached_colors === null) {
                    cached_colors = {
                        User: {},
                        Group: {},
                        Supplier: {},
                        Contact: {},
                        _dark_theme: self.dark_theme
                    };
                }
                cached_colors[itemtype][teammember['id']] = bg_color;
                window.sessionStorage.setItem('badge_colors', JSON.stringify(cached_colors));
            }

            return bg_color;
        };

        /**
       * Generate a user image based on the user's initials.
       * @since 9.5.0
       * @param {{}} teammember The teammember array/object that represents the user.
       * @return {string} HTML image of the generated user badge.
       */
        const generateUserBadge = function(teammember) {
            let initials = "";
            if (teammember["firstname"]) {
                initials += teammember["firstname"][0];
            }
            if (teammember["realname"]) {
                initials += teammember["realname"][0];
            }
            // Force uppercase initals
            initials = initials.toUpperCase();

            if (!self.display_initials || initials.length === 0) {
                return generateOtherBadge(teammember, 'fa-user');
            }

            const canvas = document.createElement('canvas');
            canvas.width = self.team_image_size;
            canvas.height = self.team_image_size;
            const context = canvas.getContext('2d');
            context.strokeStyle = "#f1f1f1";

            context.fillStyle = getBadgeColor(teammember);
            context.beginPath();
            context.arc(self.team_image_size / 2, self.team_image_size / 2, self.team_image_size / 2, 0, 2 * Math.PI);
            context.fill();
            context.fillStyle = self.dark_theme ? 'white' : 'black';
            context.textAlign = 'center';
            context.font = 'bold ' + (self.team_image_size / 2) + 'px sans-serif';
            context.textBaseline = 'middle';
            context.fillText(initials, self.team_image_size / 2, self.team_image_size / 2);
            const src = canvas.toDataURL("image/png");
            const name = teammember['name'].replace(/"/g, '&quot;').replace(/'/g, '&#39;');
            return "<span><img src='" + src + "' title='" + name + "'/></span>";
        };

        /**
       * Generate team member icon based on its name and a FontAwesome icon.
       * @since 9.5.0
       * @param {Object} teammember The team member data.
       * @param {string} icon FontAwesome icon to use for this badge.
       * @returns {string} HTML icon of the generated badge.
       */
        const generateOtherBadge = function(teammember, icon) {
            const bg_color = getBadgeColor(teammember);
            const name = teammember['name'].replace(/"/g, '&quot;').replace(/'/g, '&#39;');

            return `
            <span class='fa-stack fa-lg' style='font-size: ${(self.team_image_size / 2)}px'>
                <i class='fas fa-circle fa-stack-2x' style="color: ${bg_color}" title="${teammember['name']}"></i>
                <i class='fas ${icon} fa-stack-1x' title="${name}"></i>
            </span>
         `;
        };

        /**
       * Generate a badge to indicate that 'overflow_count' number of team members are not shown on the Kanban item.
       * @since 9.5.0
       * @param {number} overflow_count Number of members without badges on the Kanban item.
       * @returns {string} HTML image of the generated overflow badge.
       */
        const generateOverflowBadge = function(overflow_count) {
            const canvas = document.createElement('canvas');
            canvas.width = self.team_image_size;
            canvas.height = self.team_image_size;
            const context = canvas.getContext('2d');
            context.strokeStyle = "#f1f1f1";

            // Create fill color based on theme type
            const lightness = (self.dark_theme ? 40 : 80);
            context.fillStyle = "hsl(255, 0%," + lightness + "%,1)";
            context.beginPath();
            context.arc(self.team_image_size / 2, self.team_image_size / 2, self.team_image_size / 2, 0, 2 * Math.PI);
            context.fill();
            context.fillStyle = self.dark_theme ? 'white' : 'black';
            context.textAlign = 'center';
            context.font = 'bold ' + (self.team_image_size / 2) + 'px sans-serif';
            context.textBaseline = 'middle';
            context.fillText("+" + overflow_count, self.team_image_size / 2, self.team_image_size / 2);
            const src = canvas.toDataURL("image/png");
            return "<span><img src='" + src + "' title='" + __('%d other team members').replace('%d', overflow_count) + "'/></span>";
        };

        /**
       * Check if the provided color is more light or dark.
       * This function converts the given hex value into HSL and checks the L value.
       * @since 9.5.0
       * @param hex Hex code of the color. It may or may not contain the beginning '#'.
       * @returns {boolean} True if the color is more light.
       */
        const isLightColor = function(hex) {
            const c = hex.startsWith('#') ? hex.substring(1) : hex;
            const rgb = parseInt(c, 16);
            const r = (rgb >> 16) & 0xff;
            const g = (rgb >>  8) & 0xff;
            const b = (rgb >>  0) & 0xff;
            const lightness = 0.2126 * r + 0.7152 * g + 0.0722 * b;
            return lightness > 110;
        };

        /**
       * Convert a CSS RGB or RGBA string to a hex string including the '#' character.
       * @param {string} rgb The RGB or RGBA string
       * @returns {string} The hex color string
       */
        const rgbToHex = function(rgb) {
            const pattern = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+\.?\d*))?\)$/;
            const hex = rgb.match(pattern).slice(1).map((n, i) => (i === 3 ? Math.round(parseFloat(n) * 255) : parseFloat(n))
                .toString(16).padStart(2, '0') // Convert to hex values
                .replace('NaN', '') // Handle NaN values
            ).join('');
            return `#${hex}`;
        };

        /**
       * Update the counter for the specified column.
       * @since 9.5.0
       * @param {string|Element|jQuery} column_el The column
       */
        this.updateColumnCount = function(column_el) {
            if (!(column_el instanceof jQuery)) {
                column_el = $(column_el);
            }
            const column_body = $(column_el).find('.kanban-body:first');
            const counter = $(column_el).find('.kanban_nb:first');
            // Get all visible kanban items. This ensures the count is correct when items are filtered out.
            const items = column_body.find('li:not(.filtered-out)');
            counter.text(items.length);
        };

        /**
       * Remove all add item forms from the specified column.
       * @since 9.5.0
       * @param {string|Element|jQuery} column_el The column
       */
        this.clearAddItemForms = function(column_el) {
            if (!(column_el instanceof jQuery)) {
                column_el = $(column_el);
            }
            column_el.find('form').remove();
        };

        /**
       * Add a new form to the Kanban column to add a new item of the specified itemtype.
       * @since 9.5.0
       * @param {string|Element|jQuery} column_el The column
       * @param {string} itemtype The itemtype that is being added
       */
        this.showAddItemForm = function(column_el, itemtype) {
            if (!(column_el instanceof jQuery)) {
                column_el = $(column_el);
            }

            const uniqueID = Math.floor(Math.random() * 999999);
            const formID = "form_add_" + itemtype + "_" + uniqueID;
            let add_form = `<form id="${formID}" class="kanban-add-form card kanban-form no-track" data-itemtype="${itemtype}">`;
            let form_header = "<div class='kanban-item-header d-flex justify-content-between'>";
            form_header += `
            <span class='kanban-item-title'>
               <i class="${self.supported_itemtypes[itemtype]['icon']}"></i>
               ${self.supported_itemtypes[itemtype]['name']}
            </span>`;
            form_header += `<i class="ti ti-x cursor-pointer" title="${__('Close')}" onclick="$(this).parent().parent().remove()"></i></div>`;
            add_form += form_header;

            add_form += "<div class='kanban-item-content'>";
            $.each(self.supported_itemtypes[itemtype]['fields'], function(name, options) {
                const input_type = options['type'] !== undefined ? options['type'] : 'text';
                const value = options['value'] !== undefined ? options['value'] : '';

                if (input_type.toLowerCase() === 'textarea') {
                    add_form += "<textarea class='form-control' name='" + name + "'";
                    if (options['placeholder'] !== undefined) {
                        add_form += " placeholder='" + options['placeholder'] + "'";
                    }
                    if (value !== undefined) {
                        add_form += " value='" + value + "'";
                    }
                    add_form += "></textarea>";
                } else if (input_type.toLowerCase() === 'raw') {
                    add_form += value;
                } else {
                    add_form += "<input class='form-control' type='" + input_type + "' name='" + name + "'";
                    if (options['placeholder'] !== undefined) {
                        add_form += " placeholder='" + options['placeholder'] + "'";
                    }
                    if (value !== undefined) {
                        add_form += " value='" + value + "'";
                    }
                    add_form += "/>";
                }
            });
            add_form += "</div>";

            const column_id_elements = column_el.prop('id').split('-');
            const column_value = column_id_elements[column_id_elements.length - 1];
            add_form += "<input type='hidden' name='" + self.column_field.id + "' value='" + column_value + "'/>";
            add_form += "<input type='submit' value='" + __('Add') + "' name='add' class='btn btn-primary'/>";
            add_form += "</form>";
            $(column_el.find('.kanban-body')[0]).append(add_form);
            $('#' + formID).get(0).scrollIntoView(false);
        };

        /**
       * Add a new form to the Kanban column to add multiple new items of the specified itemtype.
       * @since 9.5.0
       * @param {string|Element|jQuery} column_el The column
       * @param {string} itemtype The itemtype that is being added
       */
        this.showBulkAddItemForm = function(column_el, itemtype) {
            if (!(column_el instanceof jQuery)) {
                column_el = $(column_el);
            }

            const uniqueID = Math.floor(Math.random() * 999999);
            const formID = "form_add_" + itemtype + "_" + uniqueID;
            let add_form = "<form id='" + formID + "' class='kanban-add-form kanban-bulk-add-form kanban-form no-track'>";

            add_form += `
            <div class='kanban-item-header'>
                <span class='kanban-item-title'>
                   <i class="${self.supported_itemtypes[itemtype]['icon']}"></i>
                   ${self.supported_itemtypes[itemtype]['name']}
                </span>
                <i class='ti ti-x' title='Close' onclick='$(this).parent().parent().remove()'></i>
                <div>
                    <span class="kanban-item-subtitle">${__("One item per line")}</span>
                 </div>
           </div>
         `;

            add_form += "<div class='kanban-item-content'>";
            $.each(self.supported_itemtypes[itemtype]['fields'], function(name, options) {
                const input_type = options['type'] !== undefined ? options['type'] : 'text';
                const value = options['value'] !== undefined ? options['value'] : '';

                // We want to include all hidden fields as they are usually mandatory (project ID)
                if (input_type === 'hidden') {
                    add_form += "<input type='hidden' name='" + name + "'";
                    if (value !== undefined) {
                        add_form += " value='" + value + "'";
                    }
                    add_form += "/>";
                } else if (input_type.toLowerCase() === 'raw') {
                    add_form += value;
                }
            });
            add_form += "<textarea name='bulk_item_list'></textarea>";
            add_form += "</div>";

            const column_id_elements = column_el.prop('id').split('-');
            const column_value = column_id_elements[column_id_elements.length - 1];
            add_form += "<input type='hidden' name='" + self.column_field.id + "' value='" + column_value + "'/>";
            add_form += "<input type='submit' value='" + __('Add') + "' name='add' class='submit'/>";
            add_form += "</form>";
            $(column_el.find('.kanban-body')[0]).append(add_form);
            $('#' + formID).get(0).scrollIntoView(false);
            $("#" + formID).on('submit', function(e) {
                e.preventDefault();
                const form = $(e.target);
                const data = {
                    inputs: form.serialize(),
                    itemtype: form.prop('id').split('_')[2],
                    action: 'bulk_add_item'
                };

                $.ajax({
                    method: 'POST',
                    //async: false,
                    url: (self.ajax_root + "kanban.php"),
                    data: data
                }).done(function() {
                    $('#'+formID).remove();
                    self.refresh();
                });
            });
        };

        /**
       * Create the add column form and add it to the DOM.
       * @since 9.5.0
       */
        const buildAddColumnForm = function() {
            const uniqueID = Math.floor(Math.random() * 999999);
            const formID = "form_add_column_" + uniqueID;
            self.add_column_form = '#' + formID;
            let add_form = `
            <div id="${formID}" class="kanban-form kanban-add-column-form dropdown-menu" style="display: none">
                <form class='no-track'>
                    <div class='kanban-item-header'>
                        <span class='kanban-item-title'>${__('Add a column from existing status')}</span>
                    </div>
                    <div class='kanban-item-content'></div>
         `;
            if (self.rights.canCreateColumn()) {
                add_form += `
               <hr>${__('Or add a new status')}
               <button class='btn btn-primary kanban-create-column d-block'>${__('Create status')}</button>
            `;
            }
            add_form += "</form></div>";
            $(self.element).prepend(add_form);
        };

        /**
       * Create the create column form and add it to the DOM.
       * @since 9.5.0
       */
        const buildCreateColumnForm = function() {
            const uniqueID = Math.floor(Math.random() * 999999);
            const formID = "form_create_column_" + uniqueID;
            self.create_column_form = '#' + formID;
            let create_form = `
            <div id='${formID}' class='kanban-form kanban-create-column-form dropdown-menu' style='display: none'>
                <form class='no-track'>
                    <div class='kanban-item-header'>
                        <span class='kanban-item-title'>${__('Create status')}</span>
                    </div>
                    <div class='kanban-item-content'>
                    <input name='name' class='form-control'/>
         `;
            $.each(self.column_field.extra_fields, function(name, field) {
                if (name === undefined) {
                    return true;
                }
                let value = (field.value !== undefined) ? field.value : '';
                if (field.type === undefined || field.type === 'text') {
                    create_form += "<input name='" + name + "' value='" + value + "'/>";
                } else if (field.type === 'color') {
                    if (value.length === 0) {
                        value = '#000000';
                    }
                    create_form += "<input type='color' name='" + name + "' value='" + value + "'/>";
                }
            });
            create_form += "</div>";
            create_form += "<button type='submit' class='btn btn-primary'>" + __('Create status') + "</button>";
            create_form += "</form></div>";
            $(self.element).prepend(create_form);
        };

        /**
       * Delay the background refresh for a short amount of time.
       * This should be called any time the user is in the middle of an action so that the refresh is not disruptive.
       * @since 9.5.0
       */
        const delayRefresh = function() {
            window.clearTimeout(_backgroundRefreshTimer);
            _backgroundRefreshTimer = window.setTimeout(_backgroundRefresh, 10000);
        };

        /**
       * Refresh the Kanban with the new set of columns.
       *    This will clear all existing columns from the Kanban, and replace them with what is provided by the server.
       * @since 9.5.0
       * @param {function} success Callback for when the Kanban is successfully refreshed.
       * @param {function} fail Callback for when the Kanban fails to be refreshed.
       * @param {function} always Callback that is called regardless of the success of the refresh.
       * @param {boolean} initial_load True if this is the first load. On the first load, the user state is not saved.
       */
        this.refresh = function(success, fail, always, initial_load) {
            const _refresh = function() {
                $.ajax({
                    method: 'GET',
                    //async: false,
                    url: (self.ajax_root + "kanban.php"),
                    data: {
                        action: "refresh",
                        itemtype: self.item.itemtype,
                        items_id: self.item.items_id,
                        column_field: self.column_field.id
                    }
                }).done(function(columns, textStatus, jqXHR) {
                    preloadBadgeCache({
                        trim_cache: true
                    });
                    clearColumns();
                    self.columns = columns;
                    fillColumns();
                    // Re-filter kanban
                    self.filter();
                    if (success) {
                        success(columns, textStatus, jqXHR);
                        $(self.element).trigger('kanban:refresh');
                    }
                }).fail(function(jqXHR, textStatus, errorThrown) {
                    if (fail) {
                        fail(jqXHR, textStatus, errorThrown);
                    }
                }).always(function() {
                    if (always) {
                        always();
                    }
                });
            };
            if (initial_load === undefined || initial_load === true) {
                _refresh();
            } else {
                saveState(false, false, null, null, function() {
                    loadState(_refresh);
                });
            }

        };

        /**
       * Append a column to the Kanban
       * @param {number} column_id The ID of the column being added.
       * @param {array} column The column data array.
       * @param {string|Element|jQuery} columns_container The container that the columns are in.
       *    If left null, a new JQueryobject is created with the selector "self.element + ' .kanban-container .kanban-columns'".
       * @param {boolean} revalidate If true, all other columns are checked to see if they have an item in this new column.
       *    If they do, the item is removed from that other column and the counter is updated.
       *    This is useful if an item is changed in another tab or by another user to be in the new column after the original column was added.
       */
        const appendColumn = function(column_id, column, columns_container, revalidate) {
            if (columns_container == null) {
                columns_container = $(self.element + " .kanban-container .kanban-columns").first();
            }
            revalidate = revalidate !== undefined ? revalidate : false;

            column['id'] = "column-" + self.column_field.id + '-' + column_id;
            let collapse = '';
            let position = -1;
            $.each(self.user_state.state, function(order, s_column) {
                if (parseInt(s_column['column']) === parseInt(column_id)) {
                    position = order;
                    if (s_column['folded'] === true || s_column['folded'] === 'true') {
                        collapse = 'collapsed';
                        return false;
                    }
                }
            });
            const _protected = column['_protected'] ? 'kanban-protected' : '';
            const column_classes = "kanban-column card " + collapse + " " + _protected;

            const column_top_color = (typeof column['header_color'] !== 'undefined') ? column['header_color'] : '';
            const column_html = "<div id='" + column['id'] + "' style='border-top-color: "+column_top_color+"' class='"+column_classes+"'></div>";
            let column_el = null;
            if (position < 0) {
                column_el = $(column_html).appendTo(columns_container);
            } else {
                const prev_column = $(columns_container).find('.kanban-column:nth-child(' + (position) + ')');
                if (prev_column.length === 1) {
                    column_el = $(column_html).insertAfter(prev_column);
                } else {
                    column_el = $(column_html).appendTo(columns_container);
                }
            }
            const cards = column['items'] !== undefined ? column['items'] : [];

            const column_header = $("<header class='kanban-column-header'></header>");
            const column_content = $("<div class='kanban-column-header-content'></div>").appendTo(column_header);
            const count = column['items'] !== undefined ? column['items'].length : 0;
            const column_left = $("<span class=''></span>").appendTo(column_content);
            const column_right = $("<span class=''></span>").appendTo(column_content);
            if (self.rights.canModifyView()) {
                $(column_left).append("<i class='fas fa-caret-right fa-lg kanban-collapse-column btn btn-sm btn-ghost-secondary' title='" + __('Toggle collapse') + "'/>");
            }
            $(column_left).append("<span class='kanban-column-title badge "+(column['color_class'] || '')+"' style='background-color: "+column['header_color']+"; color: "+column['header_fg_color']+";'>" + column['name'] + "</span></span>");
            $(column_right).append("<span class='kanban_nb badge bg-secondary'>"+count+"</span>");
            $(column_right).append(getColumnToolbarElement(column));
            $(column_el).prepend(column_header);
            // Re-apply header text color to handle the actual background color now that the element is actually in the DOM.
            const column_title = $('#'+column['id']).find('.kanban-column-title').eq(0);
            let header_color = column_title.css('background-color') ? rgbToHex(column_title.css('background-color')) : '#ffffff';
            const is_header_light = header_color ? isLightColor(header_color) : !self.dark_theme;
            const header_text_class = is_header_light ? 'kanban-text-dark' : 'kanban-text-light';
            column_title.removeClass('kanban-text-light kanban-text-dark');
            column_title.addClass(header_text_class);

            const column_body = $("<ul class='kanban-body card-body'></ul>").appendTo(column_el);

            column_el.attr('data-drop-only', column['drop_only']);

            if (!column['drop_only']) {
                let added = [];
                $.each(self.user_state.state, function (i, c) {
                    if (c['column'] === column_id) {
                        $.each(c['cards'], function (i2, card) {
                            $.each(cards, function (i3, card2) {
                                if (card2['id'] === card) {
                                    appendCard(column_el, card2);
                                    added.push(card2['id']);
                                    return false;
                                }
                            });
                        });
                    }
                });

                $.each(cards, function (card_id, card) {
                    if (added.indexOf(card['id']) < 0) {
                        appendCard(column_el, card, revalidate);
                    }
                });
            } else {
                $(`
               <li class="position-relative mx-auto mt-2" style="width: 250px">
                  ${__('This column cannot support showing cards due to how many cards would be shown. You can still drag cards into this column.')}
               </li>
            `).appendTo(column_body);
            }

            refreshSortables();
        };

        /**
       * Append the card in the specified column, handle duplicate cards in case the card moved, generate badges, and update column counts.
       * @since 9.5.0
       * @param {Element|string} column_el The column to add the card to.
       * @param {Object} card The card to append.
       * @param {boolean} revalidate Check for duplicate cards.
       */
        const appendCard = function(column_el, card, revalidate = false) {
            if (revalidate) {
                const existing = $('#' + card['id']);
                if (existing !== undefined) {
                    const existing_column = existing.closest('.kanban-column');
                    existing.remove();
                    self.updateColumnCount(existing_column);
                }
            }

            const itemtype = card['id'].split('-')[0];
            const col_body = $(column_el).find('.kanban-body').first();
            const readonly = card['_readonly'] !== undefined && (card['_readonly'] === true || card['_readonly'] === 1);
            let card_el = `
            <li id="${card['id']}" class="kanban-item card ${readonly ? 'readonly' : ''} ${card['is_deleted'] ? 'deleted' : ''}">
                <div class="kanban-item-header">
                    <span class="kanban-item-title" title="${card['title_tooltip']}">
                    <i class="${self.supported_itemtypes[itemtype]['icon']}"></i>
                        ${card['title']}
                    </span>
                    <i class="kanban-item-overflow-actions fas fa-ellipsis-h btn btn-sm btn-ghost-secondary"></i>
                </div>
                <div class="kanban-item-content">${(card['content'] || '')}</div>
                <div class="kanban-item-team">
         `;
            const team_count = Object.keys(card['_team']).length;
            if (card["_team"] !== undefined && team_count > 0) {
                $.each(Object.values(card["_team"]).slice(0, self.max_team_images), function(teammember_id, teammember) {
                    card_el += getTeamBadge(teammember);
                });
                if (card["_team"].length > self.max_team_images) {
                    card_el += generateOverflowBadge(team_count - self.max_team_images);
                }
            }
            card_el += "</div></li>";
            const card_obj = $(card_el).appendTo(col_body);
            card_obj.data('form_link', card['_form_link'] || undefined);
            if (card['_metadata']) {
                $.each(card['_metadata'], (k, v) => {
                    card_obj.data(k, v);
                });
            }
            card_obj.data('_team', card['_team']);
            self.updateColumnCount(column_el);
        };

        this.refreshSearchTokenizer = () => {
            self.filter_input.tokenizer.clearAutocomplete();

            // Refresh core tags autocomplete
            self.filter_input.tokenizer.setAutocomplete('type', Object.keys(self.supported_itemtypes).map(k => `<i class="${self.supported_itemtypes[k].icon} me-1"></i>` + k));
            self.filter_input.tokenizer.setAutocomplete('milestone', ["true", "false"]);

            $(self.element).trigger('kanban:refresh_tokenizer', self.filter_input.tokenizer);
        };

        /**
       * Un-hide all filtered items.
       * This does not reset the filters as it is called whenever the items are being re-filtered.
       * To clear the filter, set self.filters to {_text: '*'} and call self.filter().
       * @since 9.5.0
       */
        this.clearFiltered = function() {
            $(self.element + ' .kanban-item').each(function(i, item) {
                $(item).removeClass('filtered-out');
            });
        };

        /**
       * Applies the current filters.
       * @since 9.5.0
       */
        this.filter = function() {
            $(self.element).trigger('kanban:pre_filter', self.filters);
            // Unhide all items in case they are no longer filtered
            self.clearFiltered();

            $(self.element + ' .kanban-item').each(function(i, item) {
                const card = $(item);
                let shown = true;
                const title = card.find("span.kanban-item-title").text().trim();

                const filter_text = (filter_data, target, matchers = ['regex', 'includes']) => {
                    if (filter_data.prefix === '#' && matchers.includes('regex')) {
                        return filter_regex_match(filter_data, target);
                    } else {
                        if (matchers.includes('includes')) {
                            filter_include(filter_data, target);
                        }
                        if (matchers.includes('equals')) {
                            filter_equal(filter_data, target);
                        }
                    }
                };

                const filter_include = (filter_data, haystack) => {
                    if ((!haystack.toLowerCase().includes(filter_data.term.toLowerCase())) !== filter_data.exclusion) {
                        shown = false;
                    }
                };

                const filter_equal = (filter_data, target) => {
                    if ((target != filter_data.term) !== filter_data.exclusion) {
                        shown = false;
                    }
                };

                const filter_regex_match = (filter_data, target) => {
                    try {
                        if ((!target.trim().match(filter_data.term)) !== filter_data.exclusion) {
                            shown = false;
                        }
                    } catch (e) {
                        // Invalid regex
                        glpi_toast_error(
                            __('The regular expression you entered is invalid. Please check it and try again.'),
                            __('Invalid regular expression')
                        );
                    }
                };

                const filter_teammember = (filter_data, itemtype) => {
                    const team_members = card.data('_team');
                    let has_matching_member = false;
                    $.each(team_members, (i, m) => {
                        if (m.itemtype === itemtype && (m.name.toLowerCase().includes(filter_data.term.toLowerCase()) !== filter_data.exclusion)) {
                            has_matching_member = true;
                        }
                    });
                    if (!has_matching_member) {
                        shown = false;
                    }
                };

                if (self.filters._text) {
                    try {
                        if (!title.match(new RegExp(self.filters._text, 'i'))) {
                            shown = false;
                        }
                    } catch (err) {
                        // Probably not a valid regular expression. Use simple contains matching.
                        if (!title.toLowerCase().includes(self.filters._text.toLowerCase())) {
                            shown = false;
                        }
                    }
                }

                if (self.filters.title !== undefined) {
                    filter_text(self.filters.title, title);
                }

                if (self.filters.type !== undefined) {
                    filter_text(self.filters.type, card.attr('id').split('-')[0], ['regex', 'equals']);
                }

                if (self.filters.milestone !== undefined) {
                    self.filters.milestone.term = (self.filters.milestone.term == '0' || self.filters.milestone.term == 'false') ? 0 : 1;
                    filter_equal(self.filters.milestone, card.data('is_milestone'));
                }

                if (self.filters.content !== undefined) {
                    filter_text(self.filters.content, card.data('content'));
                }

                if (self.filters.team !== undefined) {
                    const team_search = self.filters.team.term.toLowerCase();
                    const team_members = card.data('_team');
                    let has_matching_member = false;
                    $.each(team_members, (i, m) => {
                        if (m.name.toLowerCase().includes(team_search)) {
                            has_matching_member = true;
                        }
                    });
                    if (!has_matching_member) {
                        shown = false;
                    }
                }

                if (self.filters.user !== undefined) {
                    filter_teammember(self.filters.user, 'User');
                }

                if (self.filters.group !== undefined) {
                    filter_teammember(self.filters.group, 'Group');
                }

                if (self.filters.supplier !== undefined) {
                    filter_teammember(self.filters.supplier, 'Supplier');
                }

                if (self.filters.contact !== undefined) {
                    filter_teammember(self.filters.contact, 'Contact');
                }

                if (!shown) {
                    card.addClass('filtered-out');
                }
            });

            $(self.element).trigger('kanban:filter', {
                filters: self.filters,
                kanban_element: self.element
            });

            // Update column counters
            $(self.element + ' .kanban-column').each(function(i, column) {
                self.updateColumnCount(column);
            });
            $(self.element).trigger('kanban:post_filter', self.filters);
        };

        /**
       * Toggle the collapsed state of the specified column.
       * After toggling the collapse state, the server is notified of the change.
       * @since 9.5.0
       * @param {string|Element|jQuery} column_el The column element or object.
       */
        this.toggleCollapseColumn = function(column_el) {
            if (!(column_el instanceof jQuery)) {
                column_el = $(column_el);
            }
            column_el.toggleClass('collapsed');
            const action = column_el.hasClass('collapsed') ? 'collapse_column' : 'expand_column';
            $.ajax({
                type: "POST",
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: action,
                    column: getColumnIDFromElement(column_el),
                    kanban: self.item
                }
            });
        };

        /**
       * Load a column from the server and append it to the Kanban if it is visible.
       * @since 9.5.0
       * @param {number} column_id The ID of the column to load.
       * @param {boolean} nosave If true, the column state is not saved after adding the new column.
       *    This should be false when the state is being loaded, and new columns are being added as a part of that process.
       *    The default behaviour is to save the column state after adding the column (if successful).
    *    @param {boolean} revalidate If true, all other columns are checked to see if they have an item in this new column.
       *    If they do, the item is removed from that other column and the counter is updated.
       *    This is useful if an item is changed in another tab or by another user to be in the new column after the original column was added.
       * @param {function} callback Function to call after the column is loaded (or fails to load).
       */
        const loadColumn = function(column_id, nosave, revalidate, callback = undefined) {
            nosave = nosave !== undefined ? nosave : false;

            let skip_load = false;
            $.each(self.user_state.state, function(i, c) {
                if (parseInt(c['column']) === parseInt(column_id)) {
                    if (!c['visible']) {
                        skip_load = true;
                    }
                    return false;
                }
            });
            if (skip_load) {
                if (callback) {
                    callback();
                }
                return;
            }

            $.ajax({
                method: 'GET',
                url: (self.ajax_root + "kanban.php"),
                async: false,
                data: {
                    action: "get_column",
                    itemtype: self.item.itemtype,
                    items_id: self.item.items_id,
                    column_field: self.column_field.id,
                    column_id: column_id
                }
            }).done(function(column) {
                if (column !== undefined && Object.keys(column).length > 0) {
                    self.columns[column_id] = column[column_id];
                    appendColumn(column_id, self.columns[column_id], null, revalidate);
                }
            }).always(function() {
                if (callback) {
                    callback();
                }
            });
        };

        /**
       * Create a new column and send it to the server.
       * This will create a new item in the DB based on the item type used for columns.
       * It does not automatically add it to the Kanban.
       * @since 9.5.0
       * @param {string} name The name of the new column.
       * @param {Object} params Extra fields needed to create the column.
       * @param {function} callback Function to call after the column is created (or fails to be created).
       */
        const createColumn = function(name, params, callback) {
            if (name === undefined || name.length === 0) {
                if (callback) {
                    callback();
                }
                return;
            }
            $.ajax({
                method: 'POST',
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "create_column",
                    itemtype: self.item.itemtype,
                    items_id: self.item.items_id,
                    column_field: self.column_field.id,
                    column_name: name,
                    params: params
                }
            }).always(function() {
                if (callback) {
                    callback();
                }
            });
        };

        /**
       * Update the user state object, but do not send it to the server.
       * This should only be done if there is no state stored on the server, so one needs to be built.
       * Do NOT use this for changes to the state such as moving cards/columns!
       * @since 9.5.0
       */
        const updateColumnState = function() {
            const new_state = {
                is_dirty: true,
                state: {}
            };
            $(self.element + " .kanban-column").each(function(i, element) {
                const column = $(element);
                const element_id = column.prop('id').split('-');
                const column_id = element_id[element_id.length - 1];
                if (self.user_state.state[i] === undefined || column_id !== self.user_state.state[i]['column'] ||
               self.user_state.state[i]['folded'] !== column.hasClass('collapsed')) {
                    new_state.is_dirty = true;
                }
                new_state.state[i] = {
                    column: column_id,
                    folded: column.hasClass('collapsed'),
                    cards: {}
                };
                $.each(column.find('.kanban-item'), function(i2, element2) {
                    new_state.state[i]['cards'][i2] = $(element2).prop('id');
                    if (self.user_state.state[i] !== undefined && self.user_state.state[i]['cards'] !== undefined && self.user_state.state[i]['cards'][i2] !== undefined  &&
                  self.user_state.state[i]['cards'][i2] !== new_state.state[i]['cards'][i2]) {
                        new_state.is_dirty = true;
                    }
                });
            });
            self.user_state = new_state;
        };

        this.showCardPanel = (card) => {
            if (!card) {
                $('.item-details-panel').remove();
            }
            const [itemtype, items_id] = card.prop('id').split('-');
            $.ajax({
                method: 'GET',
                url: (self.ajax_root + "kanban.php"),
                data: {
                    itemtype: itemtype,
                    items_id: items_id,
                    action: 'load_item_panel'
                }
            }).done((result) => {
                $('.item-details-panel').remove();
                $(self.element).append($(result));
                $('.item-details-panel').data('card', card);
                // Load badges
                $('.item-details-panel ul.team-list li').each((i, l) => {
                    l = $(l);
                    const member_itemtype = l.attr('data-itemtype');
                    const member_items_id = l.attr('data-items_id');
                    let member_item = getTeamBadge({
                        itemtype: member_itemtype,
                        id: member_items_id,
                        name: l.attr('data-name'),
                        realname: l.attr('data-realname'),
                        firstname: l.attr('data-firstname')
                    });
                    l.append(`
                     <div class="member-details">
                        ${member_item}
                        ${escapeMarkupText(l.attr('data-name')) || `${member_itemtype} (${member_items_id})`}
                     </div>
                     <button type="button" name="delete" class="btn btn-ghost-danger">
                        <i class="ti ti-x" title="${__('Delete')}"></i>
                     </button>
                  `);
                });
            });

            $(self.element).on('click', '.item-details-panel ul.team-list button[name="delete"]', (e) => {
                const list_item = $(e.target).closest('li');
                const member_itemtype = list_item.attr('data-itemtype');
                const member_items_id = list_item.attr('data-items_id');
                const panel = $(e.target).closest('.item-details-panel');
                const itemtype = panel.attr('data-itemtype');
                const items_id = panel.attr('data-items_id');
                const role = list_item.closest('.list-group').attr('data-role');

                if (itemtype && items_id) {
                    removeTeamMember(itemtype, items_id, member_itemtype, member_items_id, role);
                    list_item.remove();
                }
            });
        };

        this.showTeamModal = (card_el) => {
            const [card_itemtype, card_items_id] = card_el.prop('id').split('-', 2);
            let content = '';

            const teammember_types_dropdown = $(`#kanban-teammember-item-dropdown-${card_itemtype}`).html();
            content += `
            ${teammember_types_dropdown}
            <button type="button" name="add" class="btn btn-primary">${_x('button', 'Add')}</button>
         `;
            const modal = $('#kanban-modal');
            // Remove old click handlers
            modal.off('click', 'button[name="add"]');
            modal.off('click', 'button[name="delete"]');

            modal.on('click', 'button[name="add"]', () => {
                const itemtype = modal.find('select[name="itemtype"]').val();
                const items_id = modal.find('select[name="items_id"]').val();
                const role = modal.find('select[name="role"]').val();

                if (itemtype && items_id) {
                    addTeamMember(card_itemtype, card_items_id, itemtype, items_id, role).done(() => {
                        self.showCardPanel($(`#${card_itemtype}-${card_items_id}`));
                    });
                    hideModal();
                }
            });
            modal.on('click', 'button[name="delete"]', (e) => {
                const list_item = $(e.target).closest('li');
                const itemtype = list_item.attr('data-itemtype');
                const items_id = list_item.attr('data-items-id');
                const role = list_item.closest('ul').attr('data-role');

                if (itemtype && items_id) {
                    removeTeamMember(card_itemtype, card_items_id, itemtype, items_id, role).done(() => {
                        self.showCardPanel($(`#${card_itemtype}-${card_items_id}`));
                    });
                    list_item.remove();
                }
            });
            showModal(content, {
                card_el: card_el
            });
        };

        const addTeamMember = (itemtype, items_id, member_type, members_id, role) => {
            return $.ajax({
                method: 'POST',
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "add_teammember",
                    itemtype: itemtype,
                    items_id: items_id,
                    itemtype_teammember: member_type,
                    items_id_teammember: members_id,
                    role: role
                }
            }).done(() => {
                self.refresh(null, null, function() {
                    _backgroundRefreshTimer = window.setTimeout(_backgroundRefresh, self.background_refresh_interval * 60 * 1000);
                }, false);
            }).fail(() => {
                glpi_toast_error(__('Failed to add team member'), __('Error'));
            });
        };

        const removeTeamMember = (itemtype, items_id, member_type, members_id, role) => {
            return $.ajax({
                method: 'POST',
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "delete_teammember",
                    itemtype: itemtype,
                    items_id: items_id,
                    itemtype_teammember: member_type,
                    items_id_teammember: members_id,
                    role: role
                }
            }).done(() => {
                self.refresh(null, null, function() {
                    _backgroundRefreshTimer = window.setTimeout(_backgroundRefresh, self.background_refresh_interval * 60 * 1000);
                }, false);
            }).fail(() => {
                glpi_toast_error(__('Failed to remove team member'), __('Error'));
            });
        };

        /**
       * Restore the Kanban state for the user from the DB if it exists.
       * This restores the visible columns and their collapsed state.
       * @since 9.5.0
       */
        const loadState = function(callback) {
            $(self.element).trigger('kanban:pre_load_state');
            $.ajax({
                type: "GET",
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "load_column_state",
                    itemtype: self.item.itemtype,
                    items_id: self.item.items_id,
                    last_load: self.last_refresh
                }
            }).done(function(state) {
                if (state['state'] === undefined || state['state'] === null || Object.keys(state['state']).length === 0) {
                    if (callback) {
                        callback(false);
                    }
                    return;
                }
                self.user_state = {
                    is_dirty: false,
                    state: state['state']
                };

                const indices = Object.keys(state['state']);
                for (let i = 0; i < indices.length; i++) {
                    const index = indices[i];
                    const entry = state['state'][index];
                    const element = $('#column-' + self.column_field.id + "-" + entry.column);
                    if (element.length === 0) {
                        loadColumn(entry.column, true, false);
                    }
                    $(self.element + ' .kanban-columns .kanban-column:nth-child(' + index + ')').after(element);
                    if (entry.folded === 'true') {
                        element.addClass('collapsed');
                    }
                }
                self.last_refresh = state['timestamp'];

                if (callback) {
                    callback(true);
                    $(self.element).trigger('kanban:post_load_state');
                }
            });
        };

        /**
       * Saves the current state of the Kanban to the DB for the user.
       * This saves the visible columns and their collapsed state.
       * This should only be done if there is no state stored on the server, so one needs to be built.
       * Do NOT use this for changes to the state such as moving cards/columns!
       * @since 9.5.0
       * @param {boolean} rebuild_state If true, the column state is recalculated before saving.
       *    By default, this is false as updates are done as changes are made in most cases.
       * @param {boolean} force_save If true, the user state is saved even if it has not changed.
       * @param {function} success Callback for when the user state is successfully saved.
       * @param {function} fail Callback for when the user state fails to be saved.
       * @param {function} always Callback that is called regardless of the success of the save.
       */
        const saveState = function(rebuild_state, force_save, success, fail, always) {
            $(self.element).trigger('kanban:pre_save_state');
            rebuild_state = rebuild_state !== undefined ? rebuild_state : false;
            if (!force_save && !self.user_state.is_dirty) {
                if (always) {
                    always();
                }
                return;
            }
            // Reload state in case it changed in another tab/window
            if (rebuild_state) {
            // Build state of the Kanban
                updateColumnState();
            }
            if (self.user_state.state === undefined || self.user_state.state === null || Object.keys(self.user_state.state).length === 0) {
                if (always) {
                    always();
                }
                return;
            }
            $.ajax({
                type: "POST",
                url: (self.ajax_root + "kanban.php"),
                data: {
                    action: "save_column_state",
                    itemtype: self.item.itemtype,
                    items_id: self.item.items_id,
                    state: self.user_state.state
                }
            }).done(function(data, textStatus, jqXHR) {
                self.user_state.is_dirty = false;
                if (success) {
                    success(data, textStatus, jqXHR);
                    $(self.element).trigger('kanban:post_save_state');
                }
            }).fail(function(jqXHR, textStatus, errorThrown) {
                if (fail) {
                    fail(jqXHR, textStatus, errorThrown);
                }
            }).always(function() {
                if (always) {
                    always();
                }
            });
        };

        /**
       * Initialize the background refresh mechanism.
       * @since 9.5.0
       */
        const backgroundRefresh = function() {
            if (self.background_refresh_interval <= 0) {
                return;
            }
            _backgroundRefresh = function() {
                const sorting = $('.sortable-placeholder');
                // Check if the user is current sorting items
                if (sorting.length > 0) {
                    // Wait 10 seconds and try the background refresh again
                    delayRefresh();
                    return;
                }
                // Refresh and then schedule the next refresh (minutes)
                self.refresh(null, null, function() {
                    _backgroundRefreshTimer = window.setTimeout(_backgroundRefresh, self.background_refresh_interval * 60 * 1000);
                }, false);
            };
            // Schedule initial background refresh (minutes)
            _backgroundRefreshTimer = window.setTimeout(_backgroundRefresh, self.background_refresh_interval * 60 * 1000);
        };

        /**
       * Initialize the Kanban by loading the user's column state, adding the needed elements to the DOM, and starting the background save and refresh.
       * @since 9.5.0
       */
        this.init = function() {
            $(self.element).data('js_class', self);
            $(self.element).trigger('kanban:pre_init');
            loadState(function() {
                build();
                $(document).ready(function() {
                    $.ajax({
                        type: 'GET',
                        url: (self.ajax_root + 'kanban.php'),
                        data: {
                            action: 'get_switcher_dropdown',
                            itemtype: self.item.itemtype,
                            items_id: self.item.items_id
                        },
                        success: function($data) {
                            const switcher = $(self.element + " .kanban-toolbar select[name='kanban-board-switcher']");
                            switcher.replaceWith($data);
                        }
                    });
                    registerEventListeners();
                    backgroundRefresh();
                });
            });
            $(self.element).trigger('kanban:post_init');
        };

        initParams(arguments);
    };
})();

Zerion Mini Shell 1.0