/*
 * Copyright (C) 2023 Xibo Signage Ltd
 *
 * Xibo - Digital Signage - https://xibosignage.com
 *
 * This file is part of Xibo.
 *
 * Xibo is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * any later version.
 *
 * Xibo 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 Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Xibo.  If not, see <http://www.gnu.org/licenses/>.
 */
// Common functions/tools
const Common = require('../editor-core/common.js');
const PlayerHelper = require('../helpers/player-helper.js');

// LOCAL FUNCTIONS

// Check condition
function checkCondition(type, value, targetValue, isTopLevel = true) {
  if (type === 'ne' && value === '') {
    return true;
  } else if (type === 'eq' && targetValue == value) {
    return true;
  } else if (type === 'neq' && targetValue != value) {
    return true;
  } else if (type === 'gt' &&
    !Number.isNaN(parseInt(targetValue)) &&
    !Number.isNaN(parseInt(value)) &&
    parseInt(targetValue) > parseInt(value)) {
    return true;
  } else if (type === 'lt' &&
    !Number.isNaN(parseInt(targetValue)) &&
    !Number.isNaN(parseInt(value)) &&
    parseInt(targetValue) < parseInt(value)) {
    return true;
  } else if (type === 'egt' &&
    !Number.isNaN(parseInt(targetValue)) &&
    !Number.isNaN(parseInt(value)) &&
    parseInt(targetValue) >= parseInt(value)) {
    return true;
  } else if (type === 'elt' &&
    !Number.isNaN(parseInt(targetValue)) &&
    !Number.isNaN(parseInt(value)) &&
    parseInt(targetValue) <= parseInt(value)) {
    return true;
  } else if (type === 'isTopLevel' && value == isTopLevel) {
    return true;
  } else {
    return false;
  }
}

/**
* Create context and pass it to createTableFromContext
* @param {object} dialog
*/
function createDisplayGroupMembersTable(dialog) {
  const control = $(dialog).find('.controlDiv');
  const context = {
    tableName: '#displaysGroupsMembersTable',
    columns: [
      {data: 'displayGroupId', responsivePriority: 2},
      {data: 'displayGroup', responsivePriority: 2},
    ],
    members: control.data().members.displayGroups,
    extra: dialog.data().extra.displayGroupsAssigned,
    id: 'displayGroupId',
    type: 'displayGroup',
    getUrl: control.data().displayGroupsGetUrl,
  };
  createTableFromContext(dialog, context);
}

/**
* Create context and pass it to createTableFromContext
* @param {object} dialog
*/
function createDisplayMembersTable(dialog) {
  const control = $(dialog).find('.controlDiv');
  const context = {
    tableName: '#displaysMembersTable',
    columns: [
      {data: 'displayId', responsivePriority: 2},
      {data: 'display', responsivePriority: 2},
      {
        data: 'mediaInventoryStatus',
        responsivePriority: 2,
        render: function(data, type, row) {
          if (type != 'display') {
            return data;
          }
          let icon = '';
          if (data == 1) {
            icon = 'fa-check';
          } else if (data == 0) {
            icon = 'fa-times';
          } else {
            icon = 'fa-cloud-download';
          }
          return '<span class=\'fa ' + icon + '\'></span>';
        },
      },
      {
        data: 'loggedIn',
        render: dataTableTickCrossColumn,
        responsivePriority: 3,
      },
      {
        name: 'clientSort',
        responsivePriority: 3,
        data: function(data) {
          return data.clientType + ' ' +
            data.clientVersion + '-' +
            data.clientCode;
        },
        visible: false,
      },
    ],
    members: control.data().members.displays,
    extra: dialog.data().extra.displaysAssigned,
    id: 'displayId',
    type: 'display',
    getUrl: control.data().displayGetUrl,
  };
  createTableFromContext(dialog, context);
}

/**
* Create context and pass it to createTableFromContext
* @param {object} dialog
*/
function createUserMembersTable(dialog) {
  const control = $(dialog).find('.controlDiv');
  const context = {
    tableName: '#userMembersTable',
    columns: [
      {data: 'userId', responsivePriority: 2},
      {data: 'userName', responsivePriority: 2},
    ],
    members: control.data().members.users,
    extra: dialog.data().extra.usersAssigned,
    id: 'userId',
    type: 'user',
    getUrl: control.data().userGetUrl,
  };
  createTableFromContext(dialog, context);
}

/**
* Create context and pass it to createTableFromContext
* @param {object} dialog
*/
function createUserGroupMembersTable(dialog) {
  const control = $(dialog).find('.controlDiv');
  const context = {
    tableName: '#userGroupMembersTable',
    columns: [
      {data: 'groupId', responsivePriority: 2},
      {data: 'group', responsivePriority: 2},
    ],
    members: control.data().members.userGroups,
    extra: dialog.data().extra.userGroupsAssigned,
    id: 'groupId',
    type: 'userGroup',
    getUrl: control.data().userGroupsGetUrl,
  };
  createTableFromContext(dialog, context);
}

/**
* Create datatable from provided context
* @param {object} dialog
* @param {object} context
*/
function createTableFromContext(dialog, context) {
  const control = $(dialog).find('.controlDiv');
  const columns = context.columns;

  columns.push({
    name: 'member',
    responsivePriority: 2,
    data: function(data, type, row) {
      if (type != 'display') {
        return data;
      }

      let checked = '';

      // Check if the element is already been checked/unchecked
      if (typeof control.data().members != 'undefined' &&
        context.members[data[context.id]] != undefined
      ) {
        checked = (context.members[data[context.id]]) ? 'checked' : '';
      } else {
        // If its not been altered, check for the original state
        if (dialog.data().extra) {
          context.extra.forEach(function(extraElement) {
            if (extraElement[context.id] == data[context.id]) {
              checked = 'checked';
            }
          });
        }
      }
      // Create checkbox
      return '<input type="checkbox" class="checkbox"' +
        ' data-member-id=' + data[context.id] + ' data-member-type="' +
        context.type + '" ' + checked + '>';
    },
  });

  const table = $(context.tableName).DataTable({
    language: dataTablesLanguage,
    serverSide: true,
    stateSave: true, stateDuration: 0,
    filter: false,
    responsive: true,
    searchDelay: 3000,
    order: [[1, 'asc']],
    ajax: {
      url: context.getUrl ?? control.data().getUrl,
      data: function(data) {
        $.extend(data,
          $(context.tableName)
            .closest('.XiboGrid')
            .find('.FilterDiv form')
            .serializeObject(),
        );
        return data;
      },
    },
    columns: columns,
  });

  table.on('draw', dataTableDraw);
  table.on('processing.dt', dataTableProcessing);
}

function assignMediaToCampaign(url, media, unassignMedia) {
  toastr.info('Assign Media', media);

  $.ajax({
    type: 'post',
    url: url,
    cache: false,
    dataType: 'json',
    data: {mediaId: media, unassignMediaId: unassignMedia},
    success: XiboSubmitResponse,
  });
}

function assignLayoutToCampaign(url, layout, unassignLayout) {
  toastr.info('Assign Layout', layout);

  $.ajax({
    type: 'post',
    url: url,
    cache: false,
    dataType: 'json',
    data: {layoutId: layout, unassignLayoutId: unassignLayout},
    success: XiboSubmitResponse,
  });
}

/**
* Set the checkbox state based on the adjacent features
* @param triggerElement
*/
function setFeatureGroupCheckboxState(triggerElement) {
  // collect up the checkboxes belonging to the same group
  const $featureGroup = triggerElement.closest('tbody.feature-group');
  const countChecked =
    $featureGroup.find('input[name=\'features[]\']:checked').length;
  const countTotal = $featureGroup.find('input[name=\'features[]\']').length;
  setCheckboxState(
    countChecked, countTotal, $featureGroup, '.feature-select-all',
  );

  // collect up the inherit checkboxes belonging to the same group
  const countInheritChecked =
    $featureGroup.find('input.inherit-group:checked').length;
  const countInheritTotal = $featureGroup.find('input.inherit-group').length;
  setCheckboxState(
    countInheritChecked, countInheritTotal, $featureGroup, '.inherit-group-all',
  );
}

/**
* Set checkbox state helper function
* @param count
* @param countTotal
* @param $selector
* @param checkboxClass
*/
function setCheckboxState(count, countTotal, $selector, checkboxClass) {
  if (count <= 0) {
    $selector.find(checkboxClass)
      .prop('checked', false)
      .prop('indeterminate', false);
  } else if (count === countTotal) {
    $selector.find(checkboxClass)
      .prop('checked', true)
      .prop('indeterminate', false);
  } else {
    $selector.find(checkboxClass)
      .prop('checked', false)
      .prop('indeterminate', true);
  }
}

// GLOBAL FUNCTIONS
window.forms = {
  /**
     * Create form inputs from an array of elements
     * @param {object} properties - The properties to set on the form
     * @param {object} targetContainer - The container to add the properties to
     * @param {string} [targetId] - Target Id ( widget, element, etc.)
     * @param {boolean} [playlistId] - If widget, the playlistId
     * @param {object[]} [propertyGroups] - Groups to add the properties to
     * @param {string} [customClass] - Custom class to add to the form fields
     * @param {boolean} [prepend] - Prepend fields instead?
     *  - If the properties are for an element
     */
  createFields: function(
    properties,
    targetContainer,
    targetId,
    playlistId,
    propertyGroups = [],
    customClass,
    prepend = false,
  ) {
    for (const key in properties) {
      if (properties.hasOwnProperty(key)) {
        const property = properties[key];

        // If element is marked as skip
        if (property.skip === true) {
          continue;
        }

        // Handle default value
        if (property.value === null && property.default !== undefined) {
          property.value = property.default;
        }

        // Handle visibility
        if (
          property.visibility.length
        ) {
          const rules = [];
          // Add all conditions to an array
          for (let i = 0; i < property.visibility.length; i++) {
            const test = property.visibility[i];
            const testObject = {
              type: test.type,
              conditions: [],
            };

            for (let j = 0; j < test.conditions.length; j++) {
              const condition = test.conditions[j];
              testObject.conditions.push({
                field: condition.field,
                type: condition.type,
                value: condition.value,
              });
            }

            rules.push(testObject);
          }

          property.visibility = JSON.stringify(rules);
        }

        // Special properties
        // Dataset selector
        if (property.type === 'datasetSelector') {
          property.datasetSearchUrl = urlsForApi.dataset.search.url;

          // If we don't have a value, set value key pair to null
          if (property.value == '') {
            property.initialValue = null;
            property.initialKey = null;
          } else {
            property.initialValue = property.value;
            property.initialKey = 'dataSetId';
          }
        }

        // Dataset Field selector
        if (property.type === 'datasetField') {
          // If we don't have a value, set value key pair to null
          if (property.value == '') {
            property.initialValue = null;
            property.initialKey = null;
          } else {
            property.initialValue = property.value;
            property.initialKey = 'datasetField';
          }
        }

        // Menu boards selector
        if (property.type === 'menuBoardSelector') {
          property.menuBoardSearchUrl = urlsForApi.menuBoard.search.url;

          // If we don't have a value, set value key pair to null
          if (property.value == '') {
            property.initialValue = null;
            property.initialKey = null;
          } else {
            property.initialValue = property.value;
            property.initialKey = 'menuId';
          }
        }

        // Menu category selector
        if (property.type === 'menuBoardCategorySelector') {
          property.menuBoardCategorySearchUrl =
            urlsForApi.menuBoard.categorySearch.url;

          // If we don't have a value, set value key pair to null
          if (property.value == '') {
            property.initialValue = null;
            property.initialKey = null;
          } else {
            property.initialValue = property.value;
            property.initialKey = 'menuCategoryId';
          }
        }

        // Media selector
        if (property.type === 'mediaSelector') {
          property.mediaSearchUrl = urlsForApi.library.get.url;

          // If we don't have a value, set value key pair to null
          if (property.value == '') {
            property.initialValue = null;
            property.initialKey = null;
          } else {
            property.initialValue = property.value;
            property.initialKey = 'mediaId';
          }
        }

        // Fonts selector
        if (property.type === 'fontSelector') {
          property.fontsSearchUrl = getFontsUrl + '?length=10000';
        }

        // Stored command selector
        if (property.type === 'commandSelector') {
          property.commandSearchUrl = urlsForApi.command.search.url;

          // If we don't have a value, set value key pair to null
          if (property.value == '') {
            property.initialValue = null;
            property.initialKey = null;
          } else {
            property.initialValue = property.value;
            property.initialKey = 'code';
          }
        }

        // Playlist Mixer
        if (property.type === 'playlistMixer') {
          property.playlistId = playlistId;
        }

        // Colour format
        if (
          property.type === 'color' &&
          property.format != ''
        ) {
          property.colorFormat = property.format;
        }

        // dashboards available services
        if (property.type === 'connectorProperties') {
          property.connectorPropertiesUrl =
            urlsForApi.connectorProperties.search.url.replace(':id', targetId) +
            '?propertyId=' + property.id;

          // If we don't have a value, set value key pair to null
          if (property.value == '') {
            property.initialValue = null;
            property.initialKey = null;
          } else {
            property.initialValue = property.value;
            property.initialKey = property.id;
          }
        }

        // Change the name of the property to the id
        property.name = property.id;

        // Create the property id based on the targetId
        if (targetId) {
          property.id = targetId + '_' + property.id;
        }

        // Check if variant="autocomplete" exists and create isAutocomplete prop
        if (property.variant && property.variant === 'autocomplete') {
          property.isAutoComplete = true;
        }

        // Append the property to the target container
        if (templates.forms.hasOwnProperty(property.type)) {
          // New field
          const $newField = $(templates.forms[property.type](
            Object.assign(
              {},
              property,
              {
                trans: (typeof propertiesPanelTrans === 'undefined') ?
                  {} :
                  propertiesPanelTrans,
              },
            ),
          ));

          // Target to append to
          let $targetContainer = $(targetContainer);

          // Check if the property has a group
          if (property.propertyGroupId) {
            // Get group object from propertyGroups
            const group = propertyGroups.find(
              (group) => group.id === property.propertyGroupId,
            );

            // Only add to group if it exists
            if (group) {
              // Check if the group already exists in the DOM, if not create it
              if (
                $(targetContainer).find('#' + property.propertyGroupId).length
              ) {
                // Set target container to be the group
                $targetContainer = $(targetContainer)
                  .find('#' + property.propertyGroupId + ' .field-container');
              } else {
                // Create the group and add it to the target container
                $targetContainer.append(
                  $(templates.forms.group({
                    id: group.id,
                    title: group.title,
                    helpText: group.helpText,
                    expanded: group.expanded,
                  })),
                );

                // Set target container to be the group field container
                $targetContainer = $(
                  $(targetContainer).find('#' + property.propertyGroupId),
                ).find('.field-container');
              }
            }
          }

          // Append the new field to the target container
          if (prepend) {
            $targetContainer.prepend($newField);
          } else {
            $targetContainer.append($newField);
          }

          // Handle help text
          if (property.helpText) {
            // Only add if not added yet
            if (
              $newField.find('.input-info-container .xibo-help-text')
                .length === 0
            ) {
              $newField.find('.input-info-container').append(
                $(templates.forms.addOns.helpText({
                  helpText: property.helpText,
                })),
              );
            }
          }

          // Handle custom popover
          if (property.customPopOver) {
            $newField.find('.input-info-container').append(
              $(templates.forms.addOns.customPopOver({
                content: property.customPopOver,
              })),
            );
          }

          // Handle player compatibility
          if (property.playerCompatibility) {
            $newField.find('.input-info-container').append(
              $(templates.forms.addOns.playerCompatibility(
                property.playerCompatibility,
              )),
            );
          }

          // Add date format helper popup when variant = dateFormat
          if (property.variant && property.variant === 'dateFormat') {
            $newField.find('label.control-label').append(
              $(templates.forms.addOns.dateFormatHelperPopup({
                content: templates['php-date-format-table'],
              })),
            );

            // Initialize popover
            $newField.find('[data-toggle="popover"]').popover({
              sanitize: false,
              trigger: 'manual',
            }).on('mouseenter', function(ev) {
              $(ev.currentTarget).popover('show');
            });
          }

          // Handle setting value to datasetField if value is defined
          if (property.type === 'datasetField') {
            if (property.value !== undefined &&
              String(property.value).length > 0
            ) {
              $newField.find('select').attr('data-value', property.value);
            }
          }

          // Handle depends on property if not already set
          if (
            property.dependsOn &&
            !$newField.attr('data-depends-on')
          ) {
            $newField.attr('data-depends-on', property.dependsOn);
          }

          // Add visibility to the field if not already set
          if (
            property.visibility.length &&
            !$newField.attr('data-visibility')
          ) {
            $newField.attr('data-visibility', property.visibility);
          }

          // Add required attribute to the field if not already set
          if (
            property?.validation?.tests?.length &&
            !$newField.attr('data-is-required')
          ) {
            let added = false;
            $.each(property.validation.tests, function(index, el) {
              $.each(el.conditions, function(condIndex, condition) {
                if (condition?.type === 'required' && !added) {
                  $newField.attr('data-is-required', true);
                  property.isRequired = true;
                  added = true;
                }
              });
            });
          }

          // Add custom class to the field if not already set
          if (customClass) {
            $newField.find('[name]').addClass(customClass);
          }
        } else {
          console.error('Form type not found: ' + property.type);
        }
      }
    }

    // Initialise tooltips
    Common.reloadTooltips(
      $(targetContainer),
      {
        position: 'left',
      },
    );
  },
  /**
   * Initialise the form fields
   * @param {string} container - Main container Jquery selector
   * @param {object} target - Target Jquery selector or object
   * @param {string} [targetId] - Target Id ( widget, element, etc.)
   * @param {boolean} [readOnlyMode=false]
   * - If the properties are element properties
   */
  initFields: function(container, target, targetId, readOnlyMode = false) {
    const forms = this;

    // Find elements, either they match
    // the children of the container or they are the target
    const findElements = function(selector, target) {
      if (target) {
        if ($(target).is(selector)) {
          return $(target);
        } else {
          // Return empty object
          return $();
        }
      }

      return $(container).find(selector);
    };

    // Dropdowns
    findElements(
      '.dropdown-input-group',
      target,
    ).each(function(_k, el) {
      const $dropdown = $(el).find('select');

      // Check if options have a value with data-content and an image
      // If so, add the image to the dropdown
      $dropdown.find('option[data-content]').each(function(_k, option) {
        const $option = $(option);
        const $dataContent = $($option.data('content'));

        // Get the image
        const $image = $dataContent.find('img');

        // Replace src with data-src
        $image.attr('src',
          assetDownloadUrl.replace(':assetId', $image.attr('src')),
        );

        // Add html back to the option
        $option.data('content', $dataContent.html());
      });
    });

    // Button group switch
    findElements(
      '.button-switch-input-group',
      target,
    ).each(function(_k, el) {
      const $buttonGroup = $(el).find('.btn-group');
      const $hiddenInput = $(el).find('input[type="hidden"]');

      $buttonGroup.find('button').on('click', function(ev) {
        const $button = $(ev.currentTarget);

        // Deselect all other buttons
        $buttonGroup.find('button')
          .removeClass('selected');
        // Add selected to target button
        $button.addClass('selected');

        // Update hidden input with chosen button value
        $hiddenInput.val($button.data('value'))
          .trigger('change');
      });
    });

    // Dataset order clause
    findElements(
      '.dataset-order-clause',
      target,
    ).each(function(_k, el) {
      const $el = $(el);
      const datasetId = $el.data('depends-on-value');

      // Initialise the dataset order clause
      // if the dataset id is not empty
      if (datasetId) {
        // Get the dataset columns
        $.ajax({
          url: urlsForApi.dataset.search.url,
          type: 'GET',
          data: {
            dataSetId: datasetId,
          },
        }).done(function(data) {
          // Get the columns
          const datasetCols = data.data[0].columns;

          // Order Clause
          const $orderClauseFields = $el.find('.order-clause-container');
          if ($orderClauseFields.length == 0) {
            return;
          }

          const $orderClauseHiddenInput =
            $el.find('#input_' + $el.data('order-id'));
          const orderClauseValues = $orderClauseHiddenInput.val() ?
            JSON.parse(
              $orderClauseHiddenInput.val(),
            ) : [];

          // Update the hidden field with a JSON string
          // of the order clauses
          const updateHiddenField = function() {
            const orderClauses = [];
            $orderClauseFields.find('.order-clause-row').each(function(
              _index,
              el,
            ) {
              const $el = $(el);
              const orderClause = $el.find('.order-clause').val();
              const orderClauseDirection =
                $el.find('.order-clause-direction').val();

              if (orderClause) {
                orderClauses.push({
                  orderClause: orderClause,
                  orderClauseDirection: orderClauseDirection,
                });
              }
            });

            // Update the hidden field with a JSON string
            $orderClauseHiddenInput.val(JSON.stringify(orderClauses))
              .trigger('change');
          };

          // Clear existing fields
          $orderClauseFields.empty();

          // Get template
          const orderClauseTemplate =
            formHelpers.getTemplate('dataSetOrderClauseTemplate');

          const ascTitle = datasetQueryBuilderTranslations.ascTitle;
          const descTitle = datasetQueryBuilderTranslations.descTitle;

          if (orderClauseValues.length == 0) {
            // Add a template row
            const context = {
              columns: datasetCols,
              title: '1',
              orderClause: '',
              orderClauseAsc: '',
              orderClauseDesc: '',
              buttonGlyph: 'fa-plus',
              ascTitle: ascTitle,
              descTitle: descTitle,
            };
            $orderClauseFields.append(orderClauseTemplate(context));
          } else {
            // For each of the existing codes, create form components
            let i = 0;
            $.each(orderClauseValues, function(_index, field) {
              i++;

              const direction = (field.orderClauseDirection == 'ASC');

              const context = {
                columns: datasetCols,
                title: i,
                orderClause: field.orderClause,
                orderClauseAsc: direction,
                orderClauseDesc: !direction,
                buttonGlyph: ((i == 1) ? 'fa-plus' : 'fa-minus'),
                ascTitle: ascTitle,
                descTitle: descTitle,
              };

              $orderClauseFields.append(orderClauseTemplate(context));
            });
          }

          // Show only on non read only mode
          if (!readOnlyMode) {
            // Nabble the resulting buttons
            $orderClauseFields.on('click', 'button', function(e) {
              e.preventDefault();

              // find the gylph
              if ($(e.currentTarget).find('i').hasClass('fa-plus')) {
                const context = {
                  columns: datasetCols,
                  title: $orderClauseFields.find('.form-inline').length + 1,
                  orderClause: '',
                  orderClauseAsc: '',
                  orderClauseDesc: '',
                  buttonGlyph: 'fa-minus',
                  ascTitle: ascTitle,
                  descTitle: descTitle,
                };
                $orderClauseFields.append(orderClauseTemplate(context));
              } else {
                // Remove this row
                $(e.currentTarget).closest('.form-inline').remove();
              }

              updateHiddenField();
            });

            // Update the hidden field when the order clause changes
            $el.on('change', 'select:not([type="hidden"])', function() {
              updateHiddenField();
            });
          } else {
            forms.makeFormReadOnly($el);
          }
        }).fail(function(jqXHR, textStatus, errorThrown) {
          console.error(jqXHR, textStatus, errorThrown);
        });
      }
    });

    // Dataset column selector
    findElements(
      '.dataset-column-selector',
      target,
    ).each(function(_k, el) {
      const $el = $(el);
      const datasetId = $el.data('depends-on-value');

      // Initialise the dataset column selector
      // if the dataset id is not empty
      if (datasetId) {
        // Get the dataset columns
        $.ajax({
          url: urlsForApi.dataset.search.url,
          type: 'GET',
          data: {
            dataSetId: datasetId,
          },
        }).done(function(data) {
          // Get the columns
          const datasetCols = data.data[0].columns;

          // Order Clause
          const $colsOutContainer = $el.find('#columnsOut');
          const $colsInContainer = $el.find('#columnsIn');

          if ($colsOutContainer.length == 0 ||
            $colsInContainer.length == 0) {
            return;
          }

          const $selectHiddenInput =
            $el.find('#input_' + $el.data('select-id'));
          const selectedValue = $selectHiddenInput.val() ?
            JSON.parse(
              $selectHiddenInput.val(),
            ) : [];

          // Update the hidden field with a JSON string
          // of the order clauses
          const updateHiddenField = function(skipSave = false) {
            const selectedCols = [];

            $colsInContainer.find('li').each(function(_index, el) {
              const colId = $(el).attr('id');
              selectedCols.push(Number(colId));
            });

            // Delete all temporary fields
            $el.find('.temp').remove();

            // Create a hidden field for each of the selected columns
            $.each(selectedCols, function(_index, col) {
              $el.append(
                '<input type="hidden" class="temp" ' +
                'name="dataSetColumnId[]" value="' +
                col + '" />',
              );
            });

            // Update the hidden field with a JSON string
            $selectHiddenInput.val(JSON.stringify(selectedCols))
              .trigger(
                'change',
                [{
                  skipSave: skipSave,
                }]);
          };

          // Clear existing fields
          $colsOutContainer.empty();
          $colsInContainer.empty();

          const colAvailableTitle =
            datasetColumnSelectorTranslations.colAvailable;
          const colSelectedTitle =
            datasetColumnSelectorTranslations.colSelected;

          // Set titles
          $el.find('.col-out-title').text(colAvailableTitle);
          $el.find('.col-in-title').text(colSelectedTitle);

          // Get the selected columns
          const datasetColsOut = [];
          const datasetColsIn = [];

          // If the column is in the dataset
          // add it to the selected columns
          // if not add it to the remaining columns
          $.each(datasetCols, function(_index, col) {
            if (selectedValue.includes(col.dataSetColumnId)) {
              datasetColsIn.push(col);
            } else {
              datasetColsOut.push(col);
            }
          });

          // Populate the available columns
          const $columnsOut = $el.find('#columnsOut');
          $.each(datasetColsOut, function(_index, col) {
            $columnsOut.append(
              '<li class="li-sortable" id="' + col.dataSetColumnId + '">' +
              col.heading +
              '</li>',
            );
          });

          // Populate the selected columns
          const $columnsIn = $el.find('#columnsIn');
          $.each(datasetColsIn, function(_index, col) {
            $columnsIn.append(
              '<li class="li-sortable" id="' + col.dataSetColumnId + '">' +
              col.heading +
              '</li>',
            );
          });

          if (!readOnlyMode) {
            // Setup lists drag and sort ( with double click )
            $el.find('#columnsIn, #columnsOut').sortable({
              connectWith: '.connectedSortable',
              dropOnEmpty: true,
              update: function() {
                updateHiddenField();
              },
            }).disableSelection();

            // Double click to switch lists
            $el.find('.li-sortable').on('dblclick', function(ev) {
              const $this = $(ev.currentTarget);
              $this.appendTo($this.parent().is('#columnsIn') ?
                $columnsOut : $columnsIn);
              updateHiddenField();
            });
          }

          // Update hidden field on start
          updateHiddenField(true);
        }).fail(function(jqXHR, textStatus, errorThrown) {
          console.error(jqXHR, textStatus, errorThrown);
        });
      }
    });

    // Dataset field selector
    findElements(
      '.dataset-field-selector',
      target,
    ).each(function(_k, el) {
      const $el = $(el);
      const $select = $el.find('select');
      const $dataTypeId = $(container).find('input[name="dataTypeId"]');
      const datasetId = $el.data('depends-on-value');
      const getFieldItems = function(fields, search) {
        return fields.reduce(function(cols, col) {
          const searchValue = String(search);
          let searchValues = [];

          if (searchValue.indexOf(',') !== -1) {
            searchValues = searchValue.split(',');
          } else {
            searchValues = [searchValue];
          }

          if (searchValues.indexOf(String(col.dataTypeId)) !== -1) {
            return [...cols, col];
          }
          return cols;
        }, []);
      };

      // Initialise the dataset fields
      // if the dataset id is not empty
      if (datasetId) {
        $el.show();
        // Get the dataset columns
        $.ajax({
          url: urlsForApi.dataset.search.url,
          type: 'GET',
          data: {
            dataSetId: datasetId,
          },
        }).done(function(data) {
          // Get the columns
          const datasetCols = data.data[0].columns || [];

          if ($dataTypeId.length > 0 && datasetCols.length > 0) {
            // Find columns with given dataTypeId
            const fieldItems =
              getFieldItems(datasetCols, $dataTypeId.val()) || [];

            if (fieldItems.length > 0) {
              $select.find('option[value]').remove();

              $.each(fieldItems, function(_k, field) {
                $select.append(
                  $('<option value="' +
                    field.heading +
                    '">' +
                    field.heading +
                    '</option>',
                  ),
                );
              });

              if ($select.data('value') !== undefined) {
                // Trigger change event
                $select.val($select.data('value'))
                  .trigger('change', [{
                    skipSave: true,
                  }]);
              } else if (fieldItems.length === 1) {
                // Set default value when 1 option exists
                $select.val(fieldItems[0].heading).trigger('change');
              } else if (fieldItems.length > 1) {
                // Set default value to first option
                $select.val(fieldItems[0].heading).trigger('change');
              }
            } else {
              $el.hide();
            }
          }
        });
      }
    });

    // Dataset filter clause
    findElements(
      '.dataset-filter-clause',
      target,
    ).each(function(_k, el) {
      const $el = $(el);
      const datasetId = $el.data('depends-on-value');

      // Initialise the dataset filter clause
      // if the dataset id is not empty
      if (datasetId) {
        // Get the dataset columns
        $.ajax({
          url: urlsForApi.dataset.search.url,
          type: 'GET',
          data: {
            dataSetId: datasetId,
          },
        }).done(function(data) {
          // Get the columns
          const datasetCols = data.data[0].columns;

          // Filter Clause
          const $filterClauseFields = $el.find('.filter-clause-container');
          if ($filterClauseFields.length == 0) {
            return;
          }

          const $filterClauseHiddenInput =
            $el.find('#input_' + $el.data('filter-id'));
          const filterClauseValues = $filterClauseHiddenInput.val() ?
            JSON.parse(
              $filterClauseHiddenInput.val(),
            ) : [];

          // Update the hidden field with a JSON string
          // of the filter clauses
          const updateHiddenField = function() {
            const filterClauses = [];
            $filterClauseFields.find('.filter-clause-row').each(function(
              _index,
              el,
            ) {
              const $el = $(el);
              const filterClause = $el.find('.filter-clause').val();
              const filterClauseOperator =
                $el.find('.filter-clause-operator').val();
              const filterClauseCriteria =
                $el.find('.filter-clause-criteria').val();
              const filterClauseValue =
                $el.find('.filter-clause-value').val();

              if (filterClause) {
                filterClauses.push({
                  filterClause: filterClause,
                  filterClauseOperator: filterClauseOperator,
                  filterClauseCriteria: filterClauseCriteria,
                  filterClauseValue: filterClauseValue,
                });
              }
            });

            // Update the hidden field with a JSON string
            $filterClauseHiddenInput.val(JSON.stringify(filterClauses))
              .trigger('change');
          };

          // Clear existing fields
          $filterClauseFields.empty();

          // Get template
          const filterClauseTemplate =
            formHelpers.getTemplate('dataSetFilterClauseTemplate');

          const filterOptions =
            datasetQueryBuilderTranslations.filterOptions;
          const filterOperatorOptions =
            datasetQueryBuilderTranslations.filterOperatorOptions;

          if (filterClauseValues.length == 0) {
            // Add a template row
            const context = {
              columns: datasetCols,
              filterOptions: filterOptions,
              filterOperatorOptions: filterOperatorOptions,
              title: '1',
              filterClause: '',
              filterClauseOperator: 'AND',
              filterClauseCriteria: '',
              filterClauseValue: '',
              buttonGlyph: 'fa-plus',
            };
            $filterClauseFields.append(filterClauseTemplate(context));
          } else {
            // For each of the existing codes, create form components
            let j = 0;
            $.each(filterClauseValues, function(_index, field) {
              j++;

              const context = {
                columns: datasetCols,
                filterOptions: filterOptions,
                filterOperatorOptions: filterOperatorOptions,
                title: j,
                filterClause: field.filterClause,
                filterClauseOperator: field.filterClauseOperator,
                filterClauseCriteria: field.filterClauseCriteria,
                filterClauseValue: field.filterClauseValue,
                buttonGlyph: ((j == 1) ? 'fa-plus' : 'fa-minus'),
              };

              $filterClauseFields.append(filterClauseTemplate(context));
            });
          }

          // Show only on non read only mode
          if (!readOnlyMode) {
            // Nabble the resulting buttons
            $filterClauseFields.on('click', 'button', function(e) {
              e.preventDefault();

              // find the gylph
              if ($(e.currentTarget).find('i').hasClass('fa-plus')) {
                const context = {
                  columns: datasetCols,
                  filterOptions: filterOptions,
                  filterOperatorOptions: filterOperatorOptions,
                  title: $filterClauseFields.find('.form-inline').length + 1,
                  filterClause: '',
                  filterClauseOperator: 'AND',
                  filterClauseCriteria: '',
                  filterClauseValue: '',
                  buttonGlyph: 'fa-minus',
                };
                $filterClauseFields.append(filterClauseTemplate(context));
              } else {
                // Remove this row
                $(e.currentTarget).closest('.form-inline').remove();
              }

              updateHiddenField();
            });

            // Update the hidden field when the filter clause changes
            $el.on('change',
              'select:not([type="hidden"]), input:not([type="hidden"])',
              function(ev) {
                updateHiddenField();
              });
          } else {
            forms.makeFormReadOnly($el);
          }
        }).fail(function(jqXHR, textStatus, errorThrown) {
          console.error(jqXHR, textStatus, errorThrown);
        });
      }
    });

    // Article tag selector
    findElements(
      '.ticker-tag-selector',
      target,
    ).each(function(_k, el) {
      const $el = $(el);

      // Get the columns
      const tickerTagsMap = {
        title: 'text',
        summary: 'text',
        content: 'text',
        author: 'text',
        permalink: 'text',
        link: 'text',
        date: 'date',
        publishedDate: 'date',
        image: 'image',
      };

      // Tag containers
      const $tagsOutContainer = $el.find('#tagsOut');
      const $tagsInContainer = $el.find('#tagsIn');

      if ($tagsOutContainer.length == 0 ||
        $tagsInContainer.length == 0) {
        return;
      }

      const $selectHiddenInput =
        $el.find('#input_' + $el.data('select-id'));
      const tagsValue = $selectHiddenInput.val() ?
        JSON.parse(
          $selectHiddenInput.val(),
        ) : [];

      const selectedTags = Object.keys(tagsValue);

      // Update the hidden field with a JSON string
      // of the tags
      const updateHiddenField = _.debounce(function(skipSave = false) {
        const selectedTags = [];
        const tagsValue = $selectHiddenInput.val() ?
          JSON.parse(
            $selectHiddenInput.val(),
          ) : [];

        $tagsInContainer.find('li').each(function(_index, tagEl) {
          const tagId = $(tagEl).attr('id');
          selectedTags.push(tagId);
        });

        // Clear all previous style controls
        $el.find('.ticker-tag-styles .ticker-tag-style').remove();

        // Add styles for each selected tag
        selectedTags.forEach((tag) => {
          const $newStyle = $(templates.forms.tickerTagStyle({
            id: tag,
            type: tickerTagsMap[tag],
            value: tagsValue[tag],
            trans: tickerTagSelectorTranslations,
            fontsSearchUrl: getFontsUrl + '?length=10000',
          }));

          $newStyle.appendTo($el.find('.ticker-tag-styles'));

          // Update hidden field on input change
          $newStyle.off().on('change inputChange', updateTagsStylesHiddenField);
        });

        // Init fields
        forms.initFields($el.find('.ticker-tag-styles'));

        updateTagsStylesHiddenField();
      }, 100);

      const updateTagsStylesHiddenField = _.debounce(function() {
        const selectedTagStylesValue = {};

        $el.find('.ticker-tag-style').each(function(_index, tagContainer) {
          const $container = $(tagContainer);
          const tagId = $container.data('component-id');
          selectedTagStylesValue[tagId] = {};

          const $tagProperties =
            $(tagContainer).find('.ticker-tag-style-property');

          // save tag type
          selectedTagStylesValue[tagId]['tagType'] = tickerTagsMap[tagId];

          $tagProperties.find('select, input').each(function(_index, tagInput) {
            const inputType = $(tagInput).data('tag-style-input');
            const value = ($(tagInput).attr('type') === 'checkbox') ?
              $(tagInput).is(':checked') :
              $(tagInput).val();
            selectedTagStylesValue[tagId][inputType] = value;
          });
        });

        // Update the hidden field with a JSON string
        $selectHiddenInput.val(JSON.stringify(selectedTagStylesValue))
          .trigger('change');
      }, 100);

      // Clear existing fields
      $tagsOutContainer.empty();
      $tagsInContainer.empty();

      const tagAvailableTitle =
        tickerTagSelectorTranslations.tagAvailable;
      const tagSelectedTitle =
        tickerTagSelectorTranslations.tagSelected;

      // Set titles
      $el.find('.col-out-title').text(tagAvailableTitle);
      $el.find('.col-in-title').text(tagSelectedTitle);

      // Get the selected tags
      const tickerTagsOut = [];
      const tickerTagsIn = [];

      // If the column is in the dataset
      // add it to the selected tags
      // if not add it to the remaining tags
      $.each(Object.keys(tickerTagsMap), function(_index, tag) {
        if (selectedTags.includes(tag)) {
          tickerTagsIn.push(tag);
        } else {
          tickerTagsOut.push(tag);
        }
      });

      // Populate the available tags
      const $tagsOut = $el.find('#tagsOut');
      $.each(tickerTagsOut, function(_index, tag) {
        const $tag = $(
          '<li class="li-sortable" id="' + tag +
          '" data-tag-type="' + tickerTagsMap[tag] + '">' +
          tickerTagSelectorTranslations[tag] +
          '</li>',
        ).data('tag-type', tickerTagsMap[tag]);

        $tagsOut.append($tag);
      });

      // Populate the selected tags
      const $tagsIn = $el.find('#tagsIn');
      $.each(tickerTagsIn, function(_index, tag) {
        const $tag = $(
          '<li class="li-sortable" id="' + tag +
          '" data-tag-type="' + tickerTagsMap[tag] + '">' +
          tickerTagSelectorTranslations[tag] +
          '</li>',
        );

        $tagsIn.append($tag);
      });

      if (!readOnlyMode) {
        // Setup lists drag and sort ( with double click )
        $el.find('#tagsIn, #tagsOut').sortable({
          connectWith: '.connectedSortable',
          dropOnEmpty: true,
          receive: updateHiddenField,
          update: updateHiddenField,
        }).disableSelection();

        // Double click to switch lists
        $el.find('.li-sortable').on('dblclick', function(ev) {
          const $this = $(ev.currentTarget);
          $this.appendTo($this.parent().is('#tagsIn') ?
            $tagsOut : $tagsIn);
          updateHiddenField();
        });
      }

      // Update hidden field on start
      updateHiddenField(true);
    });

    // Dataset col selector
    findElements(
      '.dataset-column-style-selector',
      target,
    ).each(function(_k, el) {
      const $el = $(el);
      const datasetId = $el.data('depends-on-value');

      // Initialise the dataset column style selector
      // if the dataset id is not empty
      if (datasetId) {
        // Get the dataset columns
        $.ajax({
          url: urlsForApi.dataset.search.url,
          type: 'GET',
          data: {
            dataSetId: datasetId,
          },
        }).done(function(data) {
          // Get the columns
          const datasetCols = data.data[0].columns;

          const datasetTypeMap = {
            3: 'date',
            5: 'image',
          };

          const getColStyle = function(col) {
            return (datasetTypeMap[col]) ? datasetTypeMap[col] : 'text';
          };

          const getColById = function(colId) {
            return datasetCols.find((col) => col.dataSetColumnId == colId);
          };

          // Column containers
          const $colsOutContainer = $el.find('#colsOut');
          const $colsInContainer = $el.find('#colsIn');

          if ($colsOutContainer.length == 0 ||
            $colsInContainer.length == 0) {
            return;
          }

          const $selectHiddenInput =
            $el.find('#input_' + $el.data('select-id'));
          const colsValue = $selectHiddenInput.val() ?
            JSON.parse(
              $selectHiddenInput.val(),
            ) : [];

          const selectedCols = Object.keys(colsValue);

          // Update the hidden field with a JSON string
          // of the columns
          const updateHiddenField = _.debounce(function(skipSave = false) {
            const selectedCols = [];
            const colsValue = $selectHiddenInput.val() ?
              JSON.parse(
                $selectHiddenInput.val(),
              ) : [];

            $colsInContainer.find('li').each(function(_index, colEl) {
              const colId = $(colEl).attr('id');
              const colType = getColStyle($(colEl).data('colType'));
              const colObj = getColById(colId);
              selectedCols.push({
                id: colId,
                type: colType,
                name: colObj.heading,
              });
            });

            // Clear all previous style controls
            $el.find('.dataset-column-styles .dataset-col-style').remove();

            // Add styles for each selected col
            selectedCols.forEach((col) => {
              const $newStyle = $(templates.forms.datasetColStyle({
                id: col.id,
                type: col.type,
                name: col.name,
                value: colsValue[col.id],
                trans: datasetColStyleSelectorTranslations,
                fontsSearchUrl: getFontsUrl + '?length=10000',
              }));

              $newStyle.appendTo($el.find('.dataset-column-styles'));

              // Update hidden field on input change
              $newStyle.off().on(
                'change inputChange',
                function() {
                  updateColStylesHiddenField();
                },
              );
            });

            // Init fields
            forms.initFields($el.find('.dataset-column-styles'));

            updateColStylesHiddenField();
          }, 100);

          const updateColStylesHiddenField = _.debounce(function() {
            const selectedColStylesValue = {};

            let playOrder = 0;
            $el.find('.dataset-col-style').each(function(_index, colContainer) {
              const $container = $(colContainer);
              const colId = $container.data('component-id');
              selectedColStylesValue[colId] = {};

              const $colProperties =
                $(colContainer).find('.dataset-col-style-property');

              // set play order
              selectedColStylesValue[colId].playOrder = playOrder;
              playOrder++;

              // save colId
              selectedColStylesValue[colId].colId = colId;

              $colProperties.find('select, input')
                .each(function(_index, colsInput) {
                  const inputType =
                    $(colsInput).data('dataset-col-style-input');
                  const value = ($(colsInput).attr('type') === 'checkbox') ?
                    $(colsInput).is(':checked') :
                    $(colsInput).val();
                  selectedColStylesValue[colId][inputType] = value;
                });
            });

            // Update the hidden field with a JSON string
            $selectHiddenInput.val(JSON.stringify(selectedColStylesValue))
              .trigger('change');
          }, 100);

          // Clear existing fields
          $colsOutContainer.empty();
          $colsInContainer.empty();

          const colsAvailableTitle =
            datasetColStyleSelectorTranslations.colAvailable;
          const colsSelectedTitle =
            datasetColStyleSelectorTranslations.colSelected;

          // Set titles
          $el.find('.col-out-title').text(colsAvailableTitle);
          $el.find('.col-in-title').text(colsSelectedTitle);

          // Get the selected cols
          const colsOut = [];
          const colsIn = [];

          // If the column is in the dataset
          // add it to the selected cols
          // if not add it to the remaining cols
          $.each(Object.keys(datasetCols), function(_index, col) {
            const colId = datasetCols[col].dataSetColumnId;
            if (selectedCols.find((column) => colId == column)) {
              colsIn.push(colId);
            } else {
              colsOut.push(colId);
            }
          });

          // Populate the available columns
          const $colsOut = $el.find('#colsOut');
          $.each(colsOut, function(_index, col) {
            const columnObj = getColById(col);
            const $col = $(
              '<li class="li-sortable" id="' +
              columnObj.dataSetColumnId +
              '" data-col-type="' + columnObj.dataTypeId + '">' +
              columnObj.heading +
              '</li>',
            ).data('col-type', columnObj.dataTypeId);

            $colsOut.append($col);
          });

          // Populate the selected columns
          const $colsIn = $el.find('#colsIn');
          $.each(colsIn, function(_index, col) {
            const columnObj = getColById(col);
            const $col = $(
              '<li class="li-sortable" id="' +
              columnObj.dataSetColumnId +
              '" data-col-type="' + columnObj.dataTypeId + '">' +
              columnObj.heading +
              '</li>',
            );

            $colsIn.append($col);
          });

          if (!readOnlyMode) {
            // Setup lists drag and sort ( with double click )
            $el.find('#colsIn, #colsOut').sortable({
              connectWith: '.connectedSortable',
              dropOnEmpty: true,
              receive: updateHiddenField,
              update: updateHiddenField,
            }).disableSelection();

            // Double click to switch lists
            $el.find('.li-sortable').on('dblclick', function(ev) {
              const $this = $(ev.currentTarget);
              $this.appendTo($this.parent().is('#colsIn') ?
                $colsOut : $colsIn);
              updateHiddenField();
            });
          }

          // Update hidden field on start
          updateHiddenField(true);
        }).fail(function(jqXHR, textStatus, errorThrown) {
          console.error(jqXHR, textStatus, errorThrown);
        });
      }
    });

    findElements(
      '.languageSelect',
      target,
    ).each(function(_k, el) {
      const $select = $(el).find('select.form-control');
      const isMomentLocaleSelect = $(el).hasClass('momentLocales');

      if (isMomentLocaleSelect) {
        const momentLocales = moment.locales().sort();
        const selectValue = $select.val();

        // Remove all previous options
        $select.find('option').remove();

        // Add empty option to the start of the array
        momentLocales.unshift('');

        // Add options from moment
        for (let index = 0; index < momentLocales.length; index++) {
          const locale = momentLocales[index];

          // Get translation if exists
          const name = momentLocalesTrans[locale] || locale;

          // Create new option
          const $newOption =
            $(`<option value="${locale}">${locale} - ${name}</option>`);

          // Check if it's selected
          if (selectValue == locale) {
            $newOption.prop('selected', true);
          }

          $newOption.appendTo($select);
        }
      }

      makeLocalSelect($select, container);
    });

    // Playlist mixer
    findElements(
      '.playlist-mixer',
      target,
    ).each(function(_k, el) {
      const $el = $(el);

      /**
       * Initialise a new row
       * @param {object} $form
       * @param {object} $row
       */
      function subplaylistInitRow($form, $row) {
        const $select = $row.find('.subplaylist-id');

        // Get the initial value.
        if ($select.data('fieldId')) {
          $.ajax({
            method: 'GET',
            url: urlsForApi.playlist.get.url +
              '?playlistId=' + $select.data('fieldId'),
            success: function(response) {
              if (response.data && response.data.length > 0) {
                // Append our initial option
                $select.append('<option value="' + response.data[0].playlistId +
                  '" data-tags="' + response.data[0].tags +
                  '" selected>' + response.data[0].name + '</option>');

                subplaylistInitSelect2($form, $select);
              } else {
                // No permissions.
                $select.parent().append(
                  '<input type="hidden" value="' +
                  $select.data('fieldId') +
                  '" name="subPlaylistId[]">' +
                  '<span title="' +
                  playlistMixerTranslations.noPermission +
                  '">' +
                  '<i class="fa fa-lock"></i>&nbsp;' +
                  playlistMixerTranslations.playlistId + ' ' +
                  $select.data('fieldId') + '</span>');
                $select.remove();
              }

              // Disable fields on non read only mode
              if (readOnlyMode) {
                forms.makeFormReadOnly($form);
              }
            },
            error: function() {
              $select.parent().append(
                '{% trans "An unknown error has occurred. Please refresh" %}',
              );
            },
          });
        } else {
          subplaylistInitSelect2($form, $select);
        }

        $row.find('select[name="spotFill[]"]').select2({
          templateResult: function(state) {
            if (!state.id) {
              return state.text;
            }
            return $(state.element).data().templateResult;
          },
          dropdownAutoWidth: true,
          minimumResultsForSearch: -1,
        });
      }

      /**
       * Initialise the select2
       * @param {object} $form
       * @param {object} $el
       */
      function subplaylistInitSelect2($form, $el) {
        $el.select2({
          dropdownAutoWidth: true,
          ajax: {
            url: urlsForApi.playlist.get.url +
              '?notPlaylistId=' + $form.data('playlistId'),
            dataType: 'json',
            delay: 250,
            data: function(params) {
              const query = {
                start: 0,
                length: 10,
                name: params.term,
              };
              if (params.page != null) {
                query.start = (params.page - 1) * 10;
              }
              return query;
            },
            processResults: function(data, params) {
              const results = [];

              $.each(data.data, function(index, el) {
                results.push({
                  id: el.playlistId,
                  text: el.name,
                });
              });

              let page = params.page || 1;
              page = (page > 1) ? page - 1 : page;

              if (page === 1 && results.length <= 0) {
                $form.find('.sub-playlist-no-playlists-message')
                  .removeClass('d-none');
              }

              return {
                results: results,
                pagination: {
                  more: (page * 10 < data.recordsTotal),
                },
              };
            },
          },
        });
      }

      const $mixerHiddenInput =
        $el.find('#input_' + $el.data('mixer-id'));

      const mixerValues = $mixerHiddenInput.val() ?
        JSON.parse(
          $mixerHiddenInput.val(),
        ) : [];

      // Filter Clause
      const $playlistItemsContainer = $el.find('.mixer-playlist-container');
      if ($playlistItemsContainer.length == 0) {
        return;
      }

      // Update the hidden field with a JSON string
      // of the filter clauses
      const updateHiddenField = function() {
        const mixerItems = [];
        $playlistItemsContainer.find('.subplaylist-item-row').each(function(
          _index,
          el,
        ) {
          const $el = $(el);
          const playlistId = $el.find('.subplaylist-id').val();
          const spots =
            $el.find('.subplaylist-spots').val();
          const spotLength =
            $el.find('.subplaylist-spots-length').val();
          const spotFill =
            $el.find('.subplaylist-spots-fill').val();

          if (playlistId) {
            mixerItems.push({
              rowNo: _index + 1,
              playlistId: playlistId,
              spots: spots,
              spotLength: spotLength,
              spotFill: spotFill,
            });
          }
        });

        // Update the hidden field with a JSON string
        $mixerHiddenInput.val(JSON.stringify(mixerItems))
          .trigger('xiboInputChange');
      };

      // Clear existing fields
      $playlistItemsContainer.empty();

      // Add header template
      const containerTemplate =
        formHelpers.getTemplate('subPlaylistContainerTemplate');
      $playlistItemsContainer.append(containerTemplate({
        trans: playlistMixerTranslations,
      }));

      // Get template
      const subPlaylistFormTemplate =
        formHelpers.getTemplate('subPlaylistFormTemplate');

      if (mixerValues.length == 0) {
        // Add a template row
        const context = {
          playlistId: '',
          spots: '',
          spotLength: '',
          spotFill: '',
          fillTitle: playlistMixerTranslations.fillTitle,
          padTitle: playlistMixerTranslations.padTitle,
          repeatTitle: playlistMixerTranslations.repeatTitle,
          fillHelpText: playlistMixerTranslations.fillHelpText,
          padHelpText: playlistMixerTranslations.padHelpText,
          repeatHelpText: playlistMixerTranslations.repeatHelpText,
        };
        $playlistItemsContainer.find('.subplaylist-items-content')
          .append(subPlaylistFormTemplate(context));
      } else {
        // For each of the existing codes, create form components
        $.each(mixerValues, function(_index, field) {
          const context = {
            playlistId: field.playlistId,
            spots: field.spots,
            spotLength: field.spotLength,
            spotFill: field.spotFill,
            fillTitle: playlistMixerTranslations.fillTitle,
            padTitle: playlistMixerTranslations.padTitle,
            repeatTitle: playlistMixerTranslations.repeatTitle,
            fillHelpText: playlistMixerTranslations.fillHelpText,
            padHelpText: playlistMixerTranslations.padHelpText,
            repeatHelpText: playlistMixerTranslations.repeatHelpText,
          };

          $playlistItemsContainer.find('.subplaylist-items-content')
            .append(subPlaylistFormTemplate(context));
        });
      }

      // Add or remove playlist item
      $playlistItemsContainer.on('click', '.subplaylist-item-btn', function(e) {
        e.preventDefault();

        // find the gylph
        if ($(e.currentTarget).find('i').hasClass('fa-plus')) {
          const context = {
            playlistId: '',
            spots: '',
            spotLength: '',
            subPlaylistIdSpotFill: '',
            fillTitle: playlistMixerTranslations.fillTitle,
            padTitle: playlistMixerTranslations.padTitle,
            repeatTitle: playlistMixerTranslations.repeatTitle,
            fillHelpText: playlistMixerTranslations.fillHelpText,
            padHelpText: playlistMixerTranslations.padHelpText,
            repeatHelpText: playlistMixerTranslations.repeatHelpText,
          };

          const $newRow = $(subPlaylistFormTemplate(context))
            .appendTo(
              $playlistItemsContainer.find('.subplaylist-items-content'),
            );

          // Initialise row
          subplaylistInitRow($el, $newRow);
        } else {
          // Remove this row
          $(e.currentTarget).closest('.subplaylist-item-row').remove();
        }

        updateHiddenField();
      });

      // Initialise all rows
      $playlistItemsContainer.find('.subplaylist-item-row')
        .each(function(_index, element) {
          subplaylistInitRow($el, $(element));
        });

      // Update the hidden field when the item changes
      $el.on('change', 'select, input', function() {
        updateHiddenField();
      });

      // Make the playlist items sortable
      $playlistItemsContainer.sortable({
        axis: 'y',
        items: '.subplaylist-item-row',
        handle: '.subplaylist-item-sort',
        containment: 'parent',
        update: function() {
          updateHiddenField();
        },
      });
    });

    // Code editor
    findElements(
      '.xibo-code-input',
      target,
    ).each(function(_k, el) {
      const $container = $(el);
      const $textArea = $container.find('.code-input');
      const inputValue = $textArea.val();
      const codeType = $textArea.data('codeType');
      let valueChanged = false;

      // Get code type, defaults to HTML
      const codeTypeFunc = (CodeMirror[codeType]) ?
        CodeMirror[codeType] : CodeMirror.html;

      new CodeMirror.EditorView({
        doc: inputValue,
        extensions: [
          CodeMirror.basicSetup,
          CodeMirror.keymap.of([CodeMirror.indentWithTab]),
          codeTypeFunc(),
          CodeMirror.EditorView.updateListener.of((update) => {
            if (
              update.docChanged &&
              update.state.doc.toString() != $textArea.val()
            ) {
              $textArea.val(update.state.doc.toString()).trigger('inputChange');

              // Trigger to enable auto save on element blur
              $textArea.trigger('editorFocus');

              // Mark value as changed
              valueChanged = true;
            }

            // If value and focus changed
            if (valueChanged && update.focusChanged) {
              valueChanged = false;
              $textArea.trigger('change');
            }
          }),
        ],
        parent: $container.find('.code-input-editor')[0],
      });

      // Expand functionality
      const $codeEditor = $container.find('.code-input-editor-container');
      const toggleEditor = function() {
        const isOpened = $codeEditor.hasClass('code-input-fs');
        const codeEditorHeight = $codeEditor.height();

        // Toggle class
        $codeEditor.toggleClass('code-input-fs');

        // If it's opened
        if (isOpened) {
          // Remove overlay
          $('.code-fs-overlay').remove();

          // Move editor back to original container
          $codeEditor.appendTo($container);

          // Remove placeholder
          $container.find('.code-fs-placeholder').remove();
        } else {
          // Add overlay
          const $codeFSOverlay = $('.custom-overlay:first').clone();
          $codeFSOverlay
            .removeClass('custom-overlay')
            .addClass('code-fs-overlay custom-overlay-clone')
            .appendTo($('body'))
            .on('click', toggleEditor);
          $codeFSOverlay
            .show();

          // Move editor to body
          $codeEditor.appendTo($('body'));

          // Add placeholder
          const $codeFSPlaceholder = $('<div>')
            .addClass('code-fs-placeholder')
            .css('height', codeEditorHeight);
          $codeFSPlaceholder.appendTo($container);
        }
      };

      $container.find('.code-input-fs-btn').on('click', toggleEditor);
    });

    // Colour picker
    findElements(
      '.colorpicker-input.colorpicker-form-element',
      target,
    ).each(function(_k, el) {
      // Init the colour picker
      $(el).colorpicker({
        container: $(el).find('.picker-container'),
        align: 'left',
        format: ($(el).data('colorFormat') !== undefined) ?
          $(el).data('colorFormat') :
          false,
      });

      const $inputElement = $(el).find('input');
      $inputElement.on('focusout', function() {
        // If the input is empty, set the default value
        // or clear the color preview
        if ($inputElement.val() == '') {
          // If we have a default value
          if ($(el).data('default') !== undefined) {
            const defaultValue = $(el).data('default');
            $(el).colorpicker('setValue', defaultValue);
          } else {
            // Clear the color preview
            $(el).find('.input-group-addon').css('background-color', '');
          }
        }
      });
    });

    // Colour gradient
    findElements(
      '.color-gradient',
      target,
    ).each(function(_k, el) {
      // Init the colour pickers
      $(el).find('.colorpicker-input.colorpicker-form-element').colorpicker({
        container: $(el).find('.picker-container'),
        align: 'left',
        format: ($(el).data('colorFormat') !== undefined) ?
          $(el).data('colorFormat') :
          false,
      });
      // Defaults
      const defaults = {
        color1: '#222',
        color2: '#eee',
        angle: 0,
      };

      const $inputElements =
        $(el).find('[name]:not(.color-gradient-hidden)');
      const $hiddenInput =
        $(el).find('.color-gradient-hidden');
      const $gradientType =
        $inputElements.filter('[name="gradientType"]');
      const $gradientAngle =
        $inputElements.filter('[name="gradientAngle"]');
      const $gradientColor1 =
        $inputElements.filter('[name="gradientColor1"]');
      const $gradientColor2 =
        $inputElements.filter('[name="gradientColor2"]');

      // Load values into inputs
      if ($hiddenInput.val() != '') {
        const initialValue =
          JSON.parse($hiddenInput.val());

        $gradientType.val(initialValue.type).trigger('change');
        $gradientColor1.parents('.colorpicker-input')
          .colorpicker('setValue', initialValue.color1);
        $gradientColor2.parents('.colorpicker-input')
          .colorpicker('setValue', initialValue.color2);
        $gradientAngle.val(initialValue.angle);
      }

      // Update fields visibility and default values
      const updateFields = function() {
        if ($gradientColor1.val() == '') {
          $gradientColor1.parents('.colorpicker-input')
            .colorpicker('setValue', defaults.color1);
        }
        if ($gradientColor2.val() == '') {
          $gradientColor2.parents('.colorpicker-input')
            .colorpicker('setValue', defaults.color2);
        }
        if ($gradientAngle.val() == '') {
          $gradientAngle.val(defaults.angle);
        }
        $gradientAngle.parent().toggle($gradientType.val() === 'linear');
      };

      // Mark inputs to skip saving in properties panel
      $inputElements.addClass('skip-save');

      // When changing the inputs, save to hidden input
      $inputElements.on('change', function() {
        const gradientType = $gradientType.val();
        const color1Val = $gradientColor1.val();
        const color2Val = $gradientColor2.val();
        const angleVal = $gradientAngle.val();

        // Gradient object to be saved
        const gradient = {
          type: gradientType,
          color1: (color1Val != '') ? color1Val : defaults.color1,
          color2: (color2Val != '') ? color2Val : defaults.color2,
        };

        // If gradient type is linear, save angle
        if (gradientType === 'linear') {
          gradient.angle = (angleVal != '') ? angleVal : defaults.angle;
        }

        updateFields();

        // Save value to hidden input
        $hiddenInput.val(JSON.stringify(gradient)).trigger('change');
      });

      updateFields();
    });

    // Date picker - date only
    findElements(
      '.dateControl.date:not(.datePickerHelper)',
      target,
    ).each(function(_k, el) {
      if (calendarType == 'Jalali') {
        initDatePicker(
          $(el),
          systemDateFormat,
          jsDateOnlyFormat,
          {
            altFieldFormatter: function(unixTime) {
              const newDate = moment.unix(unixTime / 1000);
              newDate.set('hour', 0);
              newDate.set('minute', 0);
              newDate.set('second', 0);
              return newDate.format(systemDateFormat);
            },
          },
        );
      } else {
        initDatePicker(
          $(el),
          systemDateFormat,
          jsDateOnlyFormat,
        );
      }
    });

    // Date picker - date and time
    findElements(
      '.dateControl.dateTime:not(.datePickerHelper)',
      target,
    ).each(function(_k, el) {
      const enableSeconds = dateFormat.includes('s');
      const enable24 = !dateFormat.includes('A');

      if (calendarType == 'Jalali') {
        initDatePicker(
          $(el),
          systemDateFormat,
          jsDateFormat,
          {
            timePicker: {
              enabled: true,
              second: {
                enabled: enableSeconds,
              },
            },
          },
        );
      } else {
        initDatePicker(
          $(el),
          systemDateFormat,
          jsDateFormat,
          {
            enableTime: true,
            time_24hr: enable24,
            enableSeconds: enableSeconds,
            altFormat: $(el).data('customFormat') ?
              $(el).data('customFormat') : jsDateFormat,
          },
        );
      }
    });

    // Date picker - month only
    findElements(
      '.dateControl.month:not(.datePickerHelper)',
      target,
    ).each(function(_k, el) {
      if (calendarType == 'Jalali') {
        initDatePicker(
          $(el),
          systemDateFormat,
          jsDateFormat,
          {
            format: $(el).data('customFormat') ?
              $(el).data('customFormat') : 'MMMM YYYY',
            viewMode: 'month',
            dayPicker: {
              enabled: false,
            },
            altFieldFormatter: function(unixTime) {
              const newDate = moment.unix(unixTime / 1000);
              newDate.set('date', 1);
              newDate.set('hour', 0);
              newDate.set('minute', 0);
              newDate.set('second', 0);

              return newDate.format(systemDateFormat);
            },
          },
        );
      } else {
        initDatePicker(
          $(el),
          systemDateFormat,
          jsDateFormat,
          {
            plugins: [new flatpickrMonthSelectPlugin({
              shorthand: false,
              dateFormat: systemDateFormat,
              altFormat: $(el).data('customFormat') ?
                $(el).data('customFormat') : 'MMMM Y',
              parseDate: function(datestr, format) {
                return moment(datestr, format, true).toDate();
              },
              formatDate: function(date, format, locale) {
                return moment(date).format(format);
              },
            })],
          },
        );
      }
    });

    // Date picker - time only
    findElements(
      '.dateControl.time:not(.datePickerHelper)',
      target,
    ).each(function(_k, el) {
      const enableSeconds = dateFormat.includes('s');

      if (calendarType == 'Jalali') {
        initDatePicker(
          $(el),
          systemTimeFormat,
          jsTimeFormat,
          {
            onlyTimePicker: true,
            format: jsTimeFormat,
            timePicker: {
              second: {
                enabled: enableSeconds,
              },
            },
            altFieldFormatter: function(unixTime) {
              const newDate = moment.unix(unixTime / 1000);
              newDate.set('second', 0);

              return newDate.format(systemTimeFormat);
            },
          },
        );
      } else {
        initDatePicker(
          $(el),
          systemTimeFormat,
          jsTimeFormat,
          {
            enableTime: true,
            noCalendar: true,
            enableSeconds: enableSeconds,
            time_24hr: true,
            altFormat: $(el).data('customFormat') ?
              $(el).data('customFormat') : jsTimeFormat,
          },
        );
      }
    });

    // Rich text input
    findElements(
      '.rich-text',
      target,
    ).each(function(_k, el) {
      formHelpers.setupCKEditor(
        container,
        $(el).attr('id'),
        true,
        null,
        false,
        true);
    });

    // World clock control
    findElements(
      '.world-clock-control',
      target,
    ).each(function(_k, el) {
      // Get clocks container
      const $clocksContainer = $(el).find('.clocksContainer');

      // Get hidden input
      const $hiddenInput = $(el).find('.world-clock-value');

      /**
       * Configure the multiple world clock form
       * @param {*} container
       * @return {void}
       */
      function configureMultipleWorldClocks(container) {
        if (container.length == 0) {
          return;
        }

        const worldClockTemplate =
          formHelpers.getTemplate('worldClockTemplate');
        const worldClocks = $hiddenInput.attr('value') ?
          JSON.parse($hiddenInput.attr('value')) : [];

        if (worldClocks.length == 0) {
          // Add a template row
          const context = {
            title: '1',
            clockTimezone: '',
            timezones: timezones,
            buttonGlyph: 'fa-plus',
          };
          $(worldClockTemplate(context)).appendTo($clocksContainer);
          initClockRows(el);
        } else {
          // For each of the existing codes, create form components
          let i = 0;
          $.each(worldClocks, function(_index, field) {
            i++;

            const context = {
              title: i,
              clockTimezone: field.clockTimezone,
              clockHighlight: field.clockHighlight,
              clockLabel: field.clockLabel,
              timezones: timezones,
              buttonGlyph: ((i == 1) ? 'fa-plus' : 'fa-minus'),
            };
            $clocksContainer.append(worldClockTemplate(context));
          });
          initClockRows(el);
        }

        // Nabble the resulting buttons
        $clocksContainer.on('click', 'button', function(e) {
          e.preventDefault();

          // find the gylph
          if ($(e.currentTarget).find('i').hasClass('fa-plus')) {
            const context = {
              title: $clocksContainer.find('.form-clock').length + 1,
              clockTimezone: '',
              timezones: timezones,
              buttonGlyph: 'fa-minus',
            };
            $clocksContainer.append(worldClockTemplate(context));
            initClockRows(el);
          } else {
            // Remove this row
            $(e.currentTarget).closest('.form-clock').remove();
          }
        });
      }

      /**
       * Update the hidden input with the current clock values
       * @param {object} container
       */
      function updateClocksHiddenInput(container) {
        const worldClocks = [];
        $(container).find('.form-clock').each(function(_k, el2) {
          // Only add if the timezone is set
          if ($(el2).find('.localSelect select').val() != '') {
            worldClocks.push({
              clockTimezone: $(el2).find('.localSelect select').val(),
              clockHighlight: $(el2).find('.clockHighlight').is(':checked'),
              clockLabel: $(el2).find('.clockLabel').val(),
            });
          }
        });

        // Update the hidden input
        $hiddenInput.attr('value', JSON.stringify(worldClocks));
      }

      /**
       * Initialise the select2 elements
       * @param {object} container
       */
      function initClockRows(container) {
        // Initialise select2 elements
        $(container).find('.localSelect select.form-control')
          .each(function(_k, el2) {
            makeLocalSelect(
              $(el2),
              ($(container).hasClass('modal') ? $(container) : $('body')),
            );
          });

        // Update the hidden input when the clock values change
        $(container).find('input[type="checkbox"]').on('click', function() {
          updateClocksHiddenInput(container);
        });
        $(container).find('input[type="text"], select')
          .on('change', function() {
            updateClocksHiddenInput(container);
          });
      }

      // Setup multiple clocks
      configureMultipleWorldClocks($(el));
      initClockRows(el);
    });

    // Font selector
    findElements(
      '.font-selector',
      target,
    ).each(function(_k, el) {
      // Populate the font list with options
      const $el = $(el).find('select');
      $.ajax({
        method: 'GET',
        url: $el.data('searchUrl'),
        success: function(res) {
          if (res.data !== undefined && res.data.length > 0) {
            $.each(res.data, function(_index, element) {
              if ($el.data('value') === element.familyName) {
                $el.append(
                  $('<option value="' +
                    element.familyName +
                    '" selected>' +
                    element.name +
                    '</option>'));

                // Trigger change event
                $el.trigger(
                  'change',
                  [{
                    skipSave: true,
                  }]);
              } else {
                $el.append(
                  $('<option value="' +
                    element.familyName +
                    '">' +
                    element.name +
                    '</option>'));
              }
            });
          }

          // Disable fields on non read only mode
          if (readOnlyMode) {
            forms.makeFormReadOnly($(el));
          }
        },
      });
    });

    // Snippet selector
    findElements(
      '.snippet-selector',
      target,
    ).each(function(_k, el) {
      // Get select element
      const $select = $(el).find('select');

      // Get target field
      const targetFieldId = $select.data('target');
      const $targetField = $('[name=' + targetFieldId + ']');

      // Snippet mode
      const snippetMode = $select.data('mode');

      // Set normal snippet
      const setupSnippets = function($select) {
        formHelpers.setupSnippetsSelector(
          $select,
          function(e) {
            const value = $(e.currentTarget).val();

            // If there is no value, or target field is not found, do nothing
            if (value == undefined || value == '' || $targetField.length == 0) {
              return;
            }

            // Text to be inserted
            const text = '[' + value + ']';

            // Check if there is a CKEditor instance
            const ckeditorInstance = formHelpers
              .getCKEditorInstance('input_' + targetId + '_' + targetFieldId);

            if (ckeditorInstance) {
              // CKEditor
              formHelpers.insertToCKEditor(
                'input_' + targetId + '_' + targetFieldId,
                text,
              );
            } else if ($targetField.hasClass('code-input')) {
              // Code editor
              const editor =
                CodeMirror.EditorView.findFromDOM(
                  $targetField.next('.code-input-editor-container')[0],
                );

              // Get range
              const range = editor.viewState.state.selection.ranges[0];

              // Insert text
              editor.dispatch({
                changes: {
                  from: range.from,
                  to: range.to,
                  insert: text,
                },
                selection: {anchor: range.from + 1},
              });

              // Trigger change event
              $targetField.trigger('change');
            } else {
              // Text area
              const cursorPosition = $targetField[0].selectionStart;
              const previousText = $targetField.val();

              // Insert text to the cursor position
              $targetField.val(
                previousText.substring(0, cursorPosition) +
                text +
                previousText.substring(cursorPosition));

              // Trigger change event
              $targetField.trigger('change');
            }
          },
        );
      };

      // Setup media snippet selector
      const setupMediaSnippets = function($select) {
        // Add URL to the select element
        $select.data('searchUrl', urlsForApi.library.get.url);

        // Add library download URL to the select element
        $select.data('imageUrl', urlsForApi.library.download.url);

        formHelpers.setupMediaSelector(
          $select,
          function(e) {
            const value = $(e.currentTarget).val();

            // If there is no value, do nothing
            if (value == undefined || value == '') {
              return;
            }

            // Check if there is a CKEditor instance
            const ckeditorInstance = formHelpers
              .getCKEditorInstance('input_' + targetId + '_' + targetFieldId);

            // Text to be inserted
            const textURL = (ckeditorInstance) ?
              urlsForApi.library.download.url.replace(
                ':id',
                value,
              ) + '?preview=1' :
              '[' + value + ']';
            const text = '<img alt="" src="' + textURL + '" />';

            if (ckeditorInstance) {
              // CKEditor
              formHelpers.insertToCKEditor(
                'input_' + targetId + '_' + targetFieldId,
                text,
              );
            } else if ($targetField.length > 0) {
              // Text area
              const cursorPosition = $targetField[0].selectionStart;
              const previousText = $targetField.val();

              $targetField.val(
                previousText.substring(0, cursorPosition) +
                text +
                previousText.substring(cursorPosition));

              $targetField.trigger('change');
            }
          },
        );
      };

      if (snippetMode == 'dataSet') {
        const dataSetFied = $(el).data('dependsOn');
        const datasetId = $('[name=' + dataSetFied + ']').val();

        // Initialise the dataset order clause
        // if the dataset id is not empty
        if (datasetId) {
          // Get the dataset columns
          $.ajax({
            url: urlsForApi.dataset.search.url,
            type: 'GET',
            data: {
              dataSetId: datasetId,
            },
            success: function(response) {
              const data = response.data[0];
              if (
                data &&
                data.columns &&
                data.columns.length > 0) {
                // Clear select options
                $select.find('option[value]').remove();

                // Add data to the select options
                $.each(data.columns, function(_index, col) {
                  $select.append(
                    $('<option data-snippet-type="' +
                      col.dataType +
                      '" value="' +
                      col.heading + '|' + col.dataSetColumnId +
                      '">' +
                      col.heading +
                      '</option>'));
                });

                // If there are no options, hide
                if (data.columns.length == 0) {
                  $select.parent().hide();
                } else {
                  // Setup the snippet selector
                  setupSnippets($select);
                }

                // Disable fields on non read only mode
                if (readOnlyMode) {
                  forms.makeFormReadOnly($select.parent());
                }
              } else {
                $select.parent().hide();
              }
            },
            error: function() {
              $select.parent().append(
                '{% trans "An unknown error has occurred. Please refresh" %}',
              );
            },
          });
        }
      } else if (snippetMode == 'dataType') {
        // Get request path
        const requestPath =
          urlsForApi.widget.getDataType.url.replace(':id', targetId);

        // Get the data type snippets
        $.ajax({
          method: 'GET',
          url: requestPath,
          success: function(response) {
            if (response && response.fields && response.fields.length > 0) {
              // Clear select options
              $select.find('option[value]').remove();

              // Add data to the select options
              $.each(response.fields, function(_index, element) {
                $select.append(
                  $('<option data-snippet-type="' +
                    element.type +
                    '" value="' +
                    element.id +
                    '">' +
                    element.title +
                    '</option>'));
              });

              // If there are no options, hide
              if (response.fields.length == 0) {
                $select.parent().hide();
              } else {
                // Setup the snippet selector
                setupSnippets($select);
              }

              // Disable fields on non read only mode
              if (readOnlyMode) {
                forms.makeFormReadOnly($select.parent());
              }
            } else {
              $select.parent().hide();
            }
          },
          error: function() {
            $select.parent().append(
              '{% trans "An unknown error has occurred. Please refresh" %}',
            );
          },
        });
      } else if (snippetMode == 'options') {
        // Setup the snippet selector
        setupSnippets($select);
      } else if (snippetMode == 'media') {
        // Setup the media snippet selector
        setupMediaSnippets($select);
      }
    });

    // Effect selector
    findElements(
      '.effect-selector',
      target,
    ).each(function(_k, el) {
      // Populate the effect list with options
      const $el = $(el).find('select');
      const effectsType = $el.data('effects-type').split(' ');

      // Show option groups if we show all
      const showOptionGroups = (effectsType.indexOf('all') != -1);

      // Effects
      const effects = [
        {effect: 'none', group: 'showAll'},
        {effect: 'marqueeLeft', group: 'showAll'},
        {effect: 'marqueeRight', group: 'showAll'},
        {effect: 'marqueeUp', group: 'showAll'},
        {effect: 'marqueeDown', group: 'showAll'},
        {effect: 'noTransition', group: 'showPaged'},
        {effect: 'fade', group: 'showPaged'},
        {effect: 'fadeout', group: 'showPaged'},
        {effect: 'scrollHorz', group: 'showPaged'},
        {effect: 'scrollVert', group: 'showPaged'},
        {effect: 'flipHorz', group: 'showPaged'},
        {effect: 'flipVert', group: 'showPaged'},
        {effect: 'shuffle', group: 'showPaged'},
        {effect: 'tileSlide', group: 'showPaged'},
        {effect: 'tileBlind', group: 'showPaged'},
      ];

      // Add the options
      $.each(effects, function(_index, element) {
        // Don't add effect if it's none and
        // the target is a element or element-group
        if (
          effectsType.indexOf('noNone') != -1 &&
          element.effect === 'none'
        ) {
          return;
        }

        // Don't add effect if the target effect type isn't all or a valid type
        if (
          effectsType.indexOf('all') === -1 &&
          effectsType.indexOf(element.group) === -1
        ) {
          return;
        }

        // Show option group if it doesn't exist
        let $optionGroup = $el.find(`optgroup[data-type=${element.group}]`);
        if (
          showOptionGroups && $optionGroup.length == 0
        ) {
          $optionGroup =
            $(`<optgroup
              label="${effectsTranslations[element.group]}"
              data-type="${element.group}">
            </optgroup>`);
          $el.append($optionGroup);
        }

        // Check if we add to option group or main select
        const $appendTo = showOptionGroups ? $optionGroup : $el;

        $appendTo.append(
          $('<option value="' +
            element.effect +
            '" data-optgroup="' +
            element.group +
            '">' +
            effectsTranslations[element.effect] +
            '</option>'));
      });


      // If we have a value, select it
      if ($el.data('value') !== undefined) {
        $el.val($el.data('value'));
        // Trigger change
        $el.trigger(
          'change',
          [{
            skipSave: true,
          }]);
      }

      if ($(el).next().find('input[name="speed"]').length === 1) {
        let currentSelected = null;
        const speedInput = $(el).next().find('input[name="speed"]');
        const setEffectSpeed = function(prevEffect, newEffect, currSpeed) {
          const marqueeDefaultSpeed = 1;
          const noneMarqueeDefaultSpeed = 1000;
          const currIsMarquee = PlayerHelper.isMarquee(prevEffect);
          const isMarquee = PlayerHelper.isMarquee(newEffect);

          if (currIsMarquee && !isMarquee) {
            return noneMarqueeDefaultSpeed;
          } else if (!currIsMarquee && isMarquee) {
            return marqueeDefaultSpeed;
          } else {
            if (String(currSpeed).length === 0) {
              if (isMarquee) {
                return marqueeDefaultSpeed;
              } else {
                return noneMarqueeDefaultSpeed;
              }
            } else {
              return currSpeed;
            }
          }
        };

        $el.on('select2:open', function(e) {
          currentSelected = e.currentTarget.value;
        });

        speedInput.val(setEffectSpeed(
          currentSelected,
          currentSelected,
          speedInput.val(),
        ));

        $el.on('select2:select', function(e) {
          const data = e.params.data;
          const effect = data.id;

          speedInput.val(setEffectSpeed(
            currentSelected,
            effect,
            speedInput.val(),
          ));
        });
      }
    });

    // Font selector
    findElements(
      '.menu-board-category-selector',
      target,
    ).each(function(_k, el) {
      // Replace search url with the menuId
      const $el = $(el);
      const $elSelect = $(el).find('select');
      const dependsOnName = $el.data('depends-on');
      const $menu =
        $(container).find('[name=' + dependsOnName + ']');

      // Get menu value
      const menuId = $menu.val();

      if (menuId != '') {
        // Replace URL
        $elSelect.data(
          'search-url',
          $elSelect.data('search-url-base').replace(':id', menuId),
        );

        // Reset the select2 to update ajax url
        // $elSelect.select2('destroy');
        makePagedSelect($elSelect);
      }
    });

    // Select2 dropdown
    findElements(
      '.select2-hidden-accessible',
      target,
    ).each(function(_k, el) {
      const $el = $(el);

      $el.on('select2:open', function(event) {
        const $search = $(event.target).data('select2').dropdown?.$search;
        setTimeout(function() {
          if ($search) {
            $($search.get(0)).trigger('focus');
          }
        }, 10);
      });
    });

    // Key capture
    findElements(
      '.key-capture-input',
      target,
    ).each(function(_k, el) {
      const $target = $(container)
        .find('#input_' + $(el).data('captureTargetId'));
      const $input = $(el).find('.key-capture-area');
      const $error = $(el).find('.key-capture-error');
      const $clear = $(el).find('.clear-key-button');
      const allowedKeyCodes = [
        // Letters
        'KeyA', 'KeyB', 'KeyC', 'KeyD', 'KeyE', 'KeyF', 'KeyG',
        'KeyH', 'KeyI', 'KeyJ', 'KeyK', 'KeyL', 'KeyM', 'KeyN',
        'KeyO', 'KeyP', 'KeyQ', 'KeyR', 'KeyS', 'KeyT', 'KeyU',
        'KeyV', 'KeyW', 'KeyX', 'KeyY', 'KeyZ',
        // Top-Row Numbers
        'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5',
        'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0',
        // Numpad Numbers
        'Numpad1', 'Numpad2', 'Numpad3', 'Numpad4', 'Numpad5',
        'Numpad6', 'Numpad7', 'Numpad8', 'Numpad9', 'Numpad0',
        // Special Keys
        'Enter', 'Backspace', 'Space', 'Delete',
      ];

      // Save inital placeholder text
      const initialText =
        propertiesPanelTrans.keyCapture.clickToSetKey;

      const resetField = function(triggerBlur = true) {
        // Get back to initial placeholder
        $input.attr('placeholder', initialText);

        // Unset value
        $target.val('');

        // Hide clear button
        $clear.hide();

        // Unfocus field
        (triggerBlur) && $input.trigger('blur');

        // Clear error
        $error.html('').hide();
      };

      const setValue = function(value = '', triggerBlur = true) {
        // If no value, reset field
        if (value === '') {
          resetField(triggerBlur);
        } else if (!allowedKeyCodes.includes(value)) {
          $error.html(
            propertiesPanelTrans.keyCapture.codeNotAllowed
              .replace(':code', value),
          ).show();
        } else {
          // Set target
          $target.val(value);

          // Show key on capture
          $input.attr(
            'placeholder',
            formHelpers.formatKeyCodeToReadableFormat(value),
          );

          // Show clear button
          $clear.show();

          // Clear error
          $error.html('').hide();

          // Unfocus field
          (triggerBlur) && $input.trigger('blur');
        }
      };

      $input.on('focus', (ev) => {
        $input.attr('placeholder', propertiesPanelTrans.keyCapture.pressAKey);

        // Clear error
        $error.html('').hide();

        // Hide clear button
        $clear.hide();
      });

      $input.on('blur', (ev) => {
        // Set value on blur
        setValue($target.val(), false);
      });

      $input.on('keydown', (ev) => {
        ev.preventDefault();

        // Set value to target
        setValue(ev.code);
      });

      // Handle click to reset
      $clear.on('click', resetField);

      // On start, set value
      setValue($target.val());
    });

    let countExec = 0;
    // Stocks symbol search
    // Initialize tags input for properties panel with connectorProperties field
    findElements(
      'input[data-role=panelTagsInput]',
      target,
    ).each(function(_k, el) {
      const self = $(el);
      const autoCompleteUrl = self.data('autoCompleteUrl');
      const termKey = self.data('searchTermKey');

      if (autoCompleteUrl !== undefined && autoCompleteUrl !== '') {
        countExec++;

        // Tags input with autocomplete
        const tags = new Bloodhound({
          datumTokenizer: Bloodhound.tokenizers.whitespace,
          queryTokenizer: Bloodhound.tokenizers.whitespace,
          initialize: false,
          remote: {
            url: autoCompleteUrl,
            prepare: function(query, settings) {
              settings.data = {tag: query};

              if (termKey !== undefined && termKey !== '') {
                settings.data[termKey] = query;
              }

              return settings;
            },
            transform: function(list) {
              return $.map(list.data, function(tagObj) {
                if (countExec === 1) {
                  return {tag: tagObj.type};
                }

                return tagObj.type;
              });
            },
          },
        });

        const promise = tags.initialize();

        promise
          .done(function() {
            if (countExec > 1 || self.prev().is('.bootstrap-tagsinput')) {
              // Destroy tagsinput instance
              if (typeof self.tagsinput === 'function') {
                self.tagsinput('destroy');
              }

              countExec = 0;
            }

            const tagsInputOptions = {
              name: 'tags',
              source: tags.ttAdapter(),
            };

            if (countExec === 1) {
              tagsInputOptions.displayKey = 'tag';
              tagsInputOptions.valueKey = 'tag';
            }

            // Initialise tagsinput with autocomplete
            self.tagsinput({
              typeaheadjs: [{
                hint: true,
                highlight: true,
              }, tagsInputOptions],
            });
          })
          .fail(function() {
            console.info('Auto-complete for tag failed! Using default...');
            self.tagsinput();
          });
      } else {
        self.tagsinput();
      }
    });

    // Handle field dependencies for the container
    // only if we don't have a target
    if (!target) {
      $(container).find(
        '.xibo-form-input[data-depends-on]',
      ).each(function(_k, el) {
        const $target = $(el);
        let dependency = $target.data('depends-on');

        // If the dependency has already been added, skip
        if ($target.data('depends-on-added')) {
          return;
        }

        // Mark dependency as added to the target
        $target.attr('data-depends-on-added', true);

        // Check if the dependency value comes as an array value ( value[1])
        let dependencyArrayIndex = null;
        if (dependency.indexOf('[') !== -1 && dependency.indexOf(']') !== -1) {
          const dependencyArray = dependency.split('[');
          dependency = dependencyArray[0];
          dependencyArrayIndex = dependencyArray[1].replace(']', '');
        }

        // Add event listener to the dependency
        const base = '[name="' + dependency + '"]';

        // Add event listener to the $base element
        $(container).find(base).on('change', function(ev) {
          let valueToSet = null;
          const $base = $(ev.currentTarget);

          // If $base is a dropdown
          if ($base.is('select')) {
            // Get selected option
            const $selectedOption = $base.find(
              'option:selected',
            );

            // Check if the selected option has a data-set value
            // if not, use the value of the dropdown
            if ($selectedOption.data('set')) {
              valueToSet = $selectedOption.data('set');
            } else {
              valueToSet = $selectedOption.val();
            }
          } else if ($base.is('input[type="checkbox"]')) {
            // If $base is a checkbox
            valueToSet = $base.is(':checked');
          } else {
            valueToSet = $base.val();
          }

          // Check if value to set is a string or an array of values
          if (dependencyArrayIndex !== null && valueToSet !== null) {
            valueToSet = valueToSet.split(',')[dependencyArrayIndex];
          }

          // If the target is a checkbox, set the checked property
          if ($target.children('input[type="checkbox"]').length > 0) {
            // Set checked property value
            $target.children('input[type="checkbox"]')
              .prop('checked', valueToSet);
          } else if ($target.hasClass('colorpicker-input')) {
            // If the value is empty, clear the color picker
            if (valueToSet === '') {
              // Clear the color picker value
              $target.find('input').val('');

              // Also update the background color of the input group
              $target.find('.input-group-addon').css('background-color', '');
            } else if (
              valueToSet !== null &&
              valueToSet !== undefined &&
              Color(valueToSet).valid
            ) {
              // Add the color to the color picker
              $target.find('input')
                .colorpicker('setValue', valueToSet);

              // Also update the background color of the input group
              $target.find('.input-group-addon')
                .css('background-color', valueToSet);
            }
          } else if ($target.children('input[type="text"]').length > 0) {
            // If the target is a text input, set the value
            $target.children('input[type="text"]').val(valueToSet);
          } else {
            // For the remaining cases, set the
            // value of the dependency to the target data attribute
            $target.data('dependsOnValue', valueToSet);

            // Reset the target form field
            forms.initFields(container, $target, targetId, readOnlyMode);
          }
        });
      });
    }
  },
  /**
     * Handle form field replacements
     * @param {*} container - The form container
     * @param {*} baseObject - The base object to replace
     */
  handleFormReplacements: function(container, baseObject) {
    const regExpMatchBetweenPercent = /%([^%\s]+)%/g;

    const replaceHTML = function(htmlString) {
      htmlString = htmlString.replace(
        regExpMatchBetweenPercent,
        function(_m, group) {
          // Replace trimmed match with the value of the base object
          return group.split('.').reduce((a, b) => {
            return (a[b]) || `%${b}%`;
          }, baseObject);
        });

      return htmlString;
    };

    // Replace title and alternative title for the elements that have them
    $(container).find('.xibo-form-input > [title], .xibo-form-btn[title]')
      .each(function(_idx, el) {
        const $element = $(el);
        const elementTitle = $element.attr('title');
        const elementAlternativeTitle = $element.attr('data-original-title');

        // If theres title and it contains a replacement special character
        if (elementTitle) {
          const matches = elementTitle.match(regExpMatchBetweenPercent);
          if (matches) {
            $element.attr('title', replaceHTML(elementTitle));
          }
        }

        // If theres an aletrnative title and it
        // contains a replacement special character
        if (
          elementAlternativeTitle &&
          elementAlternativeTitle.indexOf('%') > -1
        ) {
          const matches = elementAlternativeTitle
            .match(regExpMatchBetweenPercent);
          if (matches) {
            $element.attr(
              'data-original-title',
              replaceHTML(elementAlternativeTitle));
          }
        }
      });

    // Replace inner html for input direct children
    $(container).find('.xibo-form-input > *, .xibo-form-btn')
      .each(function(_idx, el) {
        const $element = $(el);
        const elementInnerHTML = $element.html();

        // If theres inner html and it contains a replacement special character
        if (elementInnerHTML) {
          const matches = elementInnerHTML.match(regExpMatchBetweenPercent);
          if (matches) {
            $element.html(replaceHTML(elementInnerHTML));
          }
        }
      });
  },
  /**
     * Set the form conditions
     * @param {object} container - The form container
     * @param {object} baseObject - The base object
     * @param {string} targetId - The target id
     * @param {boolean} isTopLevel - Is the target parent top level
     * @param {boolean} isFormGroup - Should we hide the form-group
     */
  setConditions: function(
    container,
    baseObject,
    targetId,
    isTopLevel = true,
    isFormGroup = false,
  ) {
    $(container).find('.xibo-form-input[data-visibility]')
      .each(function(_idx, el) {
        let visibility = $(el).data('visibility');

        // Handle replacements for visibilty rules
        visibility = JSON.parse(
          JSON.stringify(visibility).replace(/\$(.*?)\$/g, function(_m, group) {
            // Replace match with the value of the base object
            return group.split('.').reduce((a, b) => a[b], baseObject);
          }),
        );

        // Check if target element for condition exists
        const isConditionTargetExists = function(test) {
          if (test?.field && targetId) {
            const $conditionTargetElem = $(container).find(
              `[name="${test.field}"]`);

            return $conditionTargetElem.length !== 0;
          }

          if (test.conditions.length > 0 && targetId) {
            for (let i = 0; i < test.conditions.length; i++) {
              const conditionField = test.conditions[i].field;
              const $conditionTargetElem = $(container)
                .find(`[name="${conditionField}"]`);

              if ($conditionTargetElem.length === 0) {
                return false;
              }
            }

            return true;
          }
        };

        // Handle a single condition
        const buildTest = function(test, $testContainer) {
          let testTargets = '';
          const testType = test.type;
          const testConditions = test.conditions;

          // Check test
          const checkTest = function() {
            let testResult;

            for (let i = 0; i < testConditions.length; i++) {
              const condition = testConditions[i];
              const fieldId = `[name="${condition.field}"]`;
              const $conditionTarget =
                $(container).find(fieldId);

              // Get condition target value based on type
              const conditionTargetValue =
                ($conditionTarget.length !== 0 &&
                  $conditionTarget.attr('type') === 'checkbox') ?
                  $conditionTarget.is(':checked') :
                  $conditionTarget.val();

              const newTestResult = checkCondition(
                condition.type,
                condition.value,
                conditionTargetValue,
                isTopLevel,
              );

              // If there are multiple conditions
              // we need to add the joining logic to them
              if (i > 0) {
                if (testType === 'and') {
                  testResult = testResult && newTestResult;
                } else if (testType === 'or') {
                  testResult = testResult || newTestResult;
                }
              } else {
                testResult = newTestResult;
              }
            }

            // If the test is true, show the element
            if (isFormGroup) {
              $testContainer.closest('.form-group').toggle(testResult);
            } else {
              $testContainer.toggle(testResult);
            }
          };

          // Get all the targets for the test
          for (let i = 0; i < test.conditions.length; i++) {
            // Add the target to the list
            const fieldId = `[name="${test.conditions[i].field}"]`;
            testTargets += fieldId;

            // If there are multiple conditions, add a comma
            if (i < test.conditions.length - 1) {
              testTargets += ',';
            }
          }

          if (String(testTargets).length > 0) {
            // Check test when any of the targets change
            $(container).find(testTargets).on('change', checkTest);
          }

          // Run on first load
          checkTest();
        };

        // If visibility tests are an array, process each one of the options
        if (Array.isArray(visibility)) {
          for (let i = 0; i < visibility.length; i++) {
            const test = visibility[i];
            buildTest(test, $(el));
          }
        } else {
          // Otherwise, process the single condition
          isConditionTargetExists(visibility) && buildTest({
            conditions: [visibility],
            test: '',
          }, $(el));
        }
      });
  },
  /**
     * Check for spacing issues on the form inputs
     * @param {object} $container - The form container
     */
  checkForSpacingIssues: function($container) {
    $container.find('input[type=text]').each(function(_idx, el) {
      formRenderDetectSpacingIssues(el);

      $(el).on('keyup', _.debounce(function() {
        formRenderDetectSpacingIssues(el);
      }, 500));
    });
  },
  /**
    * Disable all the form inputs and make it read only
    * @param {object} $formContainer - The form container
    */
  makeFormReadOnly: function($formContainer) {
    // Disable inputs, select, textarea and buttons
    $formContainer
      .find(
        'input, select, ' +
        'textarea, button:not(.copyTextAreaButton)',
      ).attr('disabled', 'disabled');

    // Disable color picker plugin
    $formContainer
      .find('.colorpicker-input i.input-group-addon')
      .off('click');

    // Hide buttons
    $formContainer.find(
      'button:not(.copyTextAreaButton), ' +
      '.date-clear-button',
    ).hide();

    // Hide bootstrap switch
    $formContainer.find('.bootstrap-switch').hide();
  },

  /**
   * Reload Rich text fields
   * @param {object} $formContainer - The form container
   */
  reloadRichTextFields: function($formContainer) {
    $formContainer.find('.rich-text').each(function(_k, el) {
      const elId = $(el).attr('id');
      const $parent = $(el).parent();

      // Hide elements in editor to avoid glitch
      $parent.addClass('loading');

      // Destroy CKEditor
      formHelpers.destroyCKEditor(elId);

      // Reload
      formHelpers.setupCKEditor(
        $formContainer,
        elId,
        true,
        null,
        false,
        true);

      // Hide text area to prevent flicker
      $parent.removeClass('loading');
    });
  },

  /**
   * Create form fields for fallback data
  * @param {object} dataType
  * @param {object} container
  * @param {object} fallbackData
  * @param {object} widget
  * @param {callback} reloadWidgetCallback
   */
  createFallbackDataForm: function(
    dataType,
    container,
    fallbackData,
    widget,
    reloadWidgetCallback,
  ) {
    const dataFields = dataType.fields;

    const createField = function(field, data) {
      const fieldClone = {...field};
      const auxId = Math.floor(Math.random() * 1000000);

      if (data != undefined) {
        fieldClone.value = data;
      }

      // Set date variant
      if (
        ['datetime', 'date', 'time', 'month'].indexOf(fieldClone.type) != -1
      ) {
        fieldClone.variant = (fieldClone.type === 'datetime') ?
          'dateTime' :
          fieldClone.type;
        fieldClone.type = 'date';
      }

      // Set image variant
      if (fieldClone.type === 'image') {
        fieldClone.type = 'mediaSelector';
        fieldClone.mediaSearchUrl = urlsForApi.library.get.url;
        fieldClone.initialValue = fieldClone.value;
        fieldClone.initialKey = 'mediaId';
      }

      // If field is type string, change it to text
      if (fieldClone.type === 'string') {
        fieldClone.type = 'text';
      }

      // Set name as id and give a unique id
      fieldClone.name = fieldClone.id;
      fieldClone.id = fieldClone.id + '_' + auxId;

      // Add custom class to prevent auto save
      fieldClone.customClass = 'fallback-property';

      let $newField;
      if (templates.forms.hasOwnProperty(fieldClone.type)) {
        $newField = $(templates.forms[fieldClone.type](fieldClone));
      }

      // Add helper to required fields
      if ($newField && $newField.is('[data-is-required="true"]')) {
        $newField.find('label')
          .append(`<span class="ml-1"
            title=${fallbackDataTrans.requiredField}>*</span>`);
      }

      return $newField;
    };

    const updateRecordPreview = function($record, recordData) {
      const $previewsContainer =
        $record.find('.fallback-data-record-previews');

      // Empty container
      $previewsContainer.empty();

      dataFields.forEach((field) => {
        if (recordData && recordData[field.id]) {
          const fieldData = recordData[field.id];

          // New field preview
          const $recordPreview = $(templates.forms.fallbackDataRecordPreview({
            trans: fallbackDataTrans,
            field: field,
            data: fieldData,
          }));

          $record.find('.fallback-data-record-previews')
            .append($recordPreview);
        }
      });
    };

    const saveRecord = function($record) {
      const recordData = {};
      let invalidRequired = false;

      // Get input fields and build data to be saved
      dataFields.forEach((field) => {
        const $field = $record.find('[name="' + field.id + '"]');
        const value = $field.val();
        const required =
          $field.parents('.fallback-property').is('[data-is-required="true"]');

        if (value == '' && required) {
          invalidRequired = true;
        } else if (value != '') {
          recordData[field.id] = value;
        }
      });

      // If record data is empty or invalid, thow error and don't save
      if ($.isEmptyObject(recordData)) {
        toastr.error(fallbackDataTrans.invalidRecordEmpty);
        return;
      } else if (invalidRequired) {
        toastr.error(fallbackDataTrans.invalidRecordRequired);
        return;
      }

      const updateRecord = function() {
        // Update preview
        updateRecordPreview($record, recordData);

        reloadWidgetCallback();

        $record.removeClass('editing');
      };

      // Save or add new record
      const recordId = $record.data('recordId');
      const displayOrder = $record.index();
      if (recordId) {
        widget.editFallbackDataRecord(recordId, recordData, displayOrder)
          .then((_res) => {
            if (_res.success) {
              updateRecord();
            }
          });
      } else {
        widget.addFallbackData(recordData, displayOrder)
          .then((_res) => {
            if (_res.success) {
              // Add id to record object
              $record.data('recordId', _res.id);
              updateRecord();
            }
          });
      }
    };

    const editRecord = function($record) {
      $record.addClass('editing');
    };

    const deleteRecord = function($record) {
      const recordId = $record.data('recordId');

      if (recordId) {
        widget.deleteFallbackDataRecord(recordId)
          .then((_res) => {
            if (_res.success) {
              // Remove object
              $record.remove();

              reloadWidgetCallback();
            }
          });
      } else {
        // Remove object
        $record.remove();
      }
    };

    const createRecord = function(data = {}) {
      const recordData = data.data;
      const displayOrder = data.displayOrder;
      const recordId = data.id;

      const $recordContainer = $(templates.forms.fallbackDataRecord({
        trans: fallbackDataTrans,
      }));

      // Add record id to data if exists
      if (recordId != undefined) {
        $recordContainer.data('recordId', recordId);
      }

      // Add display order to data if exists
      if (displayOrder != undefined) {
        $recordContainer.data('displayOrder', displayOrder);
      }

      // Add properties from data
      dataFields.forEach((field) => {
        const fieldData = (recordData) && recordData[field.id];
        const $newField = createField(field, fieldData);

        // New field input
        $recordContainer.find('.fallback-data-record-fields')
          .append($newField);
      });

      // Initialize fields
      forms.initFields($recordContainer);

      // Record preview
      updateRecordPreview($recordContainer, recordData);

      // Mark new field as editing if it's a new record
      if ($.isEmptyObject(data)) {
        $recordContainer.addClass('editing');
      }

      // Handle buttons
      $recordContainer.find('button').on('click', function(ev) {
        const actionType =
          $(ev.currentTarget).data('action');

        if (actionType == 'save-record') {
          saveRecord($recordContainer);
        } else if (actionType == 'delete-record') {
          deleteRecord($recordContainer);
        } else if (actionType == 'edit-record') {
          editRecord($recordContainer);
        }
      });

      return $recordContainer;
    };

    // Create main content
    $(container).append(templates.forms.fallbackDataContent({
      data: dataType,
      trans: fallbackDataTrans,
      showFallback: widget.getOptions().showFallback,
    }));

    const $recordsContainer =
      $(container).find('.fallback-data-records');

    // If we have existing data, add it to the control
    if (fallbackData) {
      // Sort array by display order
      fallbackData.sort((a, b) => {
        return a.displayOrder - b.displayOrder;
      });

      fallbackData.forEach((data) => {
        $recordsContainer.append(
          createRecord(data),
        );
      });

      // Call Xibo Init for the records container
      XiboInitialise('.fallback-data-records');
    }

    // Handle create record button
    $(container).find('[data-action="add-new-record"]').on('click', function() {
      $recordsContainer.append(
        createRecord(),
      );

      // Call Xibo Init for the records container
      XiboInitialise('.fallback-data-records');
    });

    // Init sortable
    $recordsContainer.sortable({
      axis: 'y',
      items: '.fallback-data-record',
      containment: 'parent',
      update: function() {
        // Create records structure
        let idxAux = 0;
        const records = [];

        $recordsContainer.find('.fallback-data-record').each((_idx, record) => {
          const recordData = $(record).data();

          if (recordData.recordId) {
            records.push({
              dataId: recordData.recordId,
              displayOrder: idxAux,
            });

            // Update data on record
            $(record).data('displayOrder', idxAux);

            idxAux++;
          }
        });

        widget.saveFallbackDataOrder(records)
          .then((_res) => {
            if (_res.success) {
              reloadWidgetCallback();
            }
          });
      },
    });
  },

  /**
  * Callback for all membership forms
  * @param {object} dialog Dialog object
  */
  membersFormOpen: function(dialog) {
    const control = $(dialog).find('.controlDiv');
    // This contains the changes made since the form open

    if (control.data().members == undefined) {
      control.data().members = {
        displays: {},
        displayGroups: {},
        users: {},
        userGroups: {},
      };
    }
    if (control.data().displayGroups) {
      createDisplayGroupMembersTable(dialog);
    }

    if (control.data().display) {
      createDisplayMembersTable(dialog);
    }

    if (control.data().userGroups) {
      createUserGroupMembersTable(dialog);
    }

    if (control.data().user) {
      createUserMembersTable(dialog);
    }

    // Bind to the checkboxes change event
    control.on('change', '.checkbox', function(ev) {
      const $checkbox = $(ev.currentTarget);

      // Update our global members data with this
      const memberId = $checkbox.data().memberId;
      const memberType = $checkbox.data().memberType;
      const value = $checkbox.is(':checked');

      if (memberType === 'display') {
        control.data().members.displays[memberId] = (value) ? 1 : 0;
      } else if (memberType === 'displayGroup') {
        control.data().members.displayGroups[memberId] = (value) ? 1 : 0;
      } else if (memberType === 'user') {
        control.data().members.users[memberId] = (value) ? 1 : 0;
      } else if (memberType === 'userGroup') {
        control.data().members.userGroups[memberId] = (value) ? 1 : 0;
      }
    });
  },

  permissionsFormOpen: function() {
    const grid = $('#permissionsTable').closest('.XiboGrid');

    // initialise the permissions array
    if (grid.data().permissions.length <= 0) {
      grid.data().permissions = {};
    }

    const table = $('#permissionsTable').DataTable({
      language: dataTablesLanguage,
      serverSide: true, stateSave: true,
      filter: false,
      searchDelay: 3000,
      order: [[0, 'asc']],
      ajax: {
        url: grid.data().url,
        data: function(d) {
          $.extend(
            d,
            grid.find('.permissionsTableFilter form').serializeObject(),
          );
        },
      },
      columns: [
        {
          data: 'group',
          render: function(data, type, row, meta) {
            if (type != 'display') {
              return data;
            }

            if (row.isUser == 1) {
              return data;
            } else {
              return '<strong>' + data + '</strong>';
            }
          },
        },
        {
          data: 'view', render: function(data, type, row, meta) {
            if (type != 'display') {
              return data;
            }

            let checked;
            if (row.groupId in grid.data().permissions) {
              const cache = grid.data().permissions[row.groupId];

              checked = (cache.view !== undefined && cache.view === 1) ? 1 : 0;
            } else {
              checked = data;
            }

            // Cached changes to this field?
            return '<input type="checkbox" data-permission="view"' +
              ' data-group-id="' +
              row.groupId + '" ' + ((checked === 1) ? 'checked' : '') + ' />';
          },
        },
        {
          data: 'edit', render: function(data, type, row, meta) {
            if (type != 'display') {
              return data;
            }

            let checked;
            if (row.groupId in grid.data().permissions) {
              const cache = grid.data().permissions[row.groupId];

              checked = (cache.edit !== undefined && cache.edit === 1) ? 1 : 0;
            } else {
              checked = data;
            }

            return '<input type="checkbox" data-permission="edit"' +
              ' data-group-id="' + row.groupId + '" ' +
              ((checked === 1) ? 'checked' : '') + ' />';
          },
        },
        {
          data: 'delete', render: function(data, type, row, meta) {
            if (type != 'display') {
              return data;
            }

            let checked;
            if (row.groupId in grid.data().permissions) {
              const cache = grid.data().permissions[row.groupId];

              checked =
                (cache.delete !== undefined && cache.delete === 1) ? 1 : 0;
            } else {
              checked = data;
            }

            return '<input type="checkbox" data-permission="delete"' +
              ' data-group-id="' + row.groupId + '" ' +
              ((checked === 1) ? 'checked' : '') + ' />';
          },
        },
      ],
    });

    table.on('draw', function(e, settings) {
      dataTableDraw(e, settings);

      // Bind to the checkboxes change event
      const target = $('#' + e.target.id);
      target.find('input[type=checkbox]').on('change', function(ev) {
        const $checkbox = $(ev.currentTarget);

        // Update our global permissions data with this
        const groupId = $checkbox.data().groupId;
        const permission = $checkbox.data().permission;
        const value = $checkbox.is(':checked');
        if (grid.data().permissions[groupId] === undefined) {
          grid.data().permissions[groupId] = {};
        }
        grid.data().permissions[groupId][permission] = (value) ? 1 : 0;
      });
    });
    table.on('processing.dt', dataTableProcessing);

    // Bind our filter
    grid.find(
      '.permissionsTableFilter form input, .permissionsTableFilter form select',
    ).on('change', function() {
      table.ajax.reload();
    });
  },

  permissionsFormSubmit: function(id) {
    const form = $('#' + id);
    const $formContainer = form.closest('.permissions-form');
    const permissions = {
      groupIds: $(form).data().permissions,
      ownerId: $formContainer.find('select[name=ownerId]').val(),
    };
    const data = $.param(permissions);

    $.ajax({
      type: 'POST',
      url: form.data().url,
      cache: false,
      dataType: 'json',
      data: data,
      success: function(xhr, textStatus, error) {
        XiboSubmitResponse(xhr, form);
      },
      error: function(xhr, textStatus, errorThrown) {
        SystemMessage(xhr.responseText, false);
      },
    });
  },

  permissionsMultiFormOpen: function(dialog) {
    const $permissionsTable = $(dialog).find('#permissionsMultiTable');
    const $grid = $permissionsTable.closest('.XiboGrid');

    const table = $permissionsTable.DataTable({
      language: dataTablesLanguage,
      serverSide: true,
      stateSave: true,
      filter: false,
      searchDelay: 3000,
      order: [[0, 'asc']],
      ajax: {
        url: $grid.data().url,
        data: function(d) {
          $.extend(d, $grid.find('.permissionsMultiTableFilter form')
            .serializeObject());

          $.extend(d, {
            ids: $grid.data().targetIds,
          });
        },
        dataSrc: function(json) {
          const newData = json.data;

          for (const dataKey in newData) {
            if (newData.hasOwnProperty(dataKey)) {
              const permissionGrouped = {
                view: null,
                edit: null,
                delete: null,
              };

              for (const key in newData[dataKey].permissions) {
                if (newData[dataKey].permissions.hasOwnProperty(key)) {
                  const permission = newData[dataKey].permissions[key];

                  if (permission.view != permissionGrouped.view) {
                    if (permissionGrouped.view != null) {
                      permissionGrouped.view = 2;
                    } else {
                      permissionGrouped.view = permission.view;
                    }
                  }

                  if (permission.edit != permissionGrouped.edit) {
                    if (permissionGrouped.edit != null) {
                      permissionGrouped.edit = 2;
                    } else {
                      permissionGrouped.edit = permission.edit;
                    }
                  }

                  if (permission.delete != permissionGrouped.delete) {
                    if (permissionGrouped.delete != null) {
                      permissionGrouped.delete = 2;
                    } else {
                      permissionGrouped.delete = permission.delete;
                    }
                  }
                }
              }

              newData[dataKey] =
                Object.assign(permissionGrouped, newData[dataKey]);
              delete newData[dataKey].permissions;
            }
          }

          // merge the permission and start permissions arrays
          $grid.data().permissions =
            Object.assign({}, $grid.data().permissions, newData);
          $grid.data().startPermissions =
            Object.assign(
              {},
              $grid.data().startPermissions,
              JSON.parse(JSON.stringify(newData)),
            );

          // init save permissions if undefined
          if ($grid.data().savePermissions == undefined) {
            $grid.data().savePermissions = {};
          }

          // Return an array of permissions
          return Object.values(newData);
        },
      },
      columns: [
        {
          data: 'group',
          render: function(data, type, row, meta) {
            if (type != 'display') {
              return data;
            }

            if (row.isUser == 1) {
              return data;
            } else {
              return '<strong>' + data + '</strong>';
            }
          },
        },
        {
          data: 'view', render: function(data, type, row, meta) {
            if (type != 'display') {
              return data;
            }

            let checked;
            if (row.groupId in $grid.data().permissions) {
              const cache = $grid.data().permissions[row.groupId];

              checked = (cache.view !== undefined && cache.view !== 0) ?
                cache.view : 0;
            } else {
              checked = data;
            }

            // Cached changes to this field?
            return '<input type="checkbox" class="' +
              ((checked === 2) ? 'indeterminate' : '') +
              '" data-permission="view" data-group-id="' + row.groupId +
              '" ' + ((checked === 1) ? 'checked' : '') + ' />';
          },
        },
        {
          data: 'edit', render: function(data, type, row, meta) {
            if (type != 'display') {
              return data;
            }

            let checked;
            if (row.groupId in $grid.data().permissions) {
              const cache = $grid.data().permissions[row.groupId];

              checked = (cache.edit !== undefined && cache.edit !== 0) ?
                cache.edit : 0;
            } else {
              checked = data;
            }

            return '<input type="checkbox" class="' +
              ((checked === 2) ? 'indeterminate' : '') +
              '" data-permission="edit" data-group-id="' + row.groupId +
              '" ' + ((checked === 1) ? 'checked' : '') + ' />';
          },
        },
        {
          data: 'delete', render: function(data, type, row, meta) {
            if (type != 'display') {
              return data;
            }

            let checked;
            if (row.groupId in $grid.data().permissions) {
              const cache = $grid.data().permissions[row.groupId];

              checked = (cache.delete !== undefined && cache.delete !== 0) ?
                cache.delete : 0;
            } else {
              checked = data;
            }

            return '<input type="checkbox" class="' +
              ((checked === 2) ? 'indeterminate' : '') +
              '" data-permission="delete" data-group-id="' +
              row.groupId + '" ' + ((checked === 1) ? 'checked' : '') + ' />';
          },
        },
      ],
    });

    table.on('draw', function(e, settings) {
      dataTableDraw(e, settings);

      // Bind to the checkboxes change event
      const target = $('#' + e.target.id);
      target.find('input[type=checkbox]').on('change', function(ev) {
        const $checkbox = $(ev.currentTarget);

        // Update our global permissions data with this
        const groupId = $checkbox.data().groupId;
        const permission = $checkbox.data().permission;
        const value = $checkbox.is(':checked');
        const valueNumeric = (value) ? 1 : 0;

        // Update main permission object
        if ($grid.data().permissions[groupId] === undefined) {
          $grid.data().permissions[groupId] = {};
        }
        $grid.data().permissions[groupId][permission] = valueNumeric;

        // Update save permissions object
        if ($grid.data().savePermissions[groupId] === undefined) {
          $grid.data().savePermissions[groupId] = {};
          $grid.data().savePermissions[groupId][permission] = valueNumeric;
        } else {
          if (
            $grid.data().startPermissions[groupId][permission] === valueNumeric
          ) {
            // if changed value is the same as the initial permission object
            // remove it from the save permissions object
            delete $grid.data().savePermissions[groupId][permission];

            // Remove group if it's an empty object
            if ($.isEmptyObject($grid.data().savePermissions[groupId])) {
              delete $grid.data().savePermissions[groupId];
            }
          } else {
            // Add new change to the save permissions object
            $grid.data().savePermissions[groupId][permission] = valueNumeric;
          }
        }

        // Enable save button only if we have permission changes to save
        $(dialog).find('.save-button').toggleClass(
          'disabled',
          $.isEmptyObject($grid.data().savePermissions),
        );
      });

      // Mark indeterminate checkboxes and add title
      target.find('input[type=checkbox].indeterminate')
        .prop('indeterminate', true).prop('title', translations.indeterminate);
    });

    // Disable save button by default
    $(dialog).find('.save-button').addClass('disabled');

    table.on('processing.dt', dataTableProcessing);

    // Bind our filter
    $grid.find(
      '.permissionsMultiTableFilter form input, ' +
      '.permissionsMultiTableFilter form select',
    ).on('change', function() {
      table.ajax.reload();
    });
  },

  permissionsMultiFormSubmit: function(id) {
    const form = $('#' + id);
    const permissions = $(form).data().savePermissions;
    const targetIds = $(form).data().targetIds;

    const data = $.param({
      groupIds: permissions,
      ids: targetIds,
    });

    $.ajax({
      type: 'POST',
      url: form.data().url,
      cache: false,
      dataType: 'json',
      data: data,
      success: function(xhr, textStatus, error) {
        XiboSubmitResponse(xhr, form);
      },
      error: function(xhr, textStatus, errorThrown) {
        SystemMessage(xhr.responseText, false);
      },
    });
  },

  /**
  * Submit membership form
  * @param {string} id The form id
  */
  membersFormSubmit: function(id) {
    const form = $('#' + id);
    const members = form.data().members;

    // There may not have been any changes
    if (members == undefined) {
      // No changes
      XiboDialogClose();
      return;
    }

    // Create a new queue.
    window.queue = $.jqmq({
      // Next item will be processed only when
      // queue.next() is called in callback.
      delay: -1,

      // Process queue items one-at-a-time.
      batch: 1,

      // For each queue item, execute this function
      // making an AJAX request. Only continue processing
      // the queue once the AJAX request's callback executes.
      callback: function(data) {
        // Make an AJAX call
        $.ajax({
          type: 'POST',
          url: data.url,
          cache: false,
          dataType: 'json',
          data: $.param(data.data),
          success: function(response, textStatus, error) {
            if (response.success) {
              // Success - what do we do now?
              if (response.message != '') {
                SystemMessage(response.message, true);
              }

              // Process the next item
              queue.next();
            } else {
              // Why did we fail?
              if (response.login) {
                // We were logged out
                LoginBox(response.message);
              } else {
                // Likely just an error that we want to report on
                // Remove the saving cog
                form.closest('.modal-dialog').find('.saving').remove();
                SystemMessageInline(response.message, form.closest('.modal'));
              }
            }
          },
          error: function(responseText) {
            // Remove the saving cog
            form.closest('.modal-dialog').find('.saving').remove();
            SystemMessage(responseText, false);
          },
        });
      },
      // When the queue completes naturally, execute this function.
      complete: function() {
        // Remove the save button
        form.closest('.modal-dialog').find('.saving').parent().remove();

        // Refresh the grids
        // (this is a global refresh)
        XiboRefreshAllGrids();

        if (form.data('nextFormUrl') !== undefined) {
          XiboFormRender(form.data().nextFormUrl);
        }
        // Close the dialog
        XiboDialogClose();
      },
    });

    let addedToQueue = false;

    // Build an array of id's to assign and an array to unassign
    const assign = [];
    const unassign = [];

    $.each(members.displays, function(name, value) {
      if (value == 1) {
        assign.push(name);
      } else {
        unassign.push(name);
      }
    });

    if (assign.length > 0 || unassign.length > 0) {
      const dataDisplays = {
        data: {},
        url: form.data().displayUrl,
      };
      dataDisplays.data[form.data().displayParam] = assign;
      dataDisplays.data[form.data().displayParamUnassign] = unassign;

      // Queue
      queue.add(dataDisplays);
      addedToQueue = true;
    }

    // Build an array of id's to assign and an array to unassign
    const assignDisplayGroup = [];
    const unassignDisplayGroup = [];

    $.each(members.displayGroups, function(name, value) {
      if (value == 1) {
        assignDisplayGroup.push(name);
      } else {
        unassignDisplayGroup.push(name);
      }
    });

    if (assignDisplayGroup.length > 0 || unassignDisplayGroup.length > 0) {
      const dataDisplayGroups = {
        data: {},
        url: form.data().displayGroupsUrl,
      };
      dataDisplayGroups.data[form.data().displayGroupsParam] =
        assignDisplayGroup;
      dataDisplayGroups.data[form.data().displayGroupsParamUnassign] =
        unassignDisplayGroup;

      // Queue
      queue.add(dataDisplayGroups);
      addedToQueue = true;
    }

    // Build an array of id's to assign and an array to unassign
    const assignUser = [];
    const unassignUser = [];

    $.each(members.users, function(name, value) {
      if (value == 1) {
        assignUser.push(name);
      } else {
        unassignUser.push(name);
      }
    });

    if (assignUser.length > 0 || unassignUser.length > 0) {
      const dataUsers = {
        data: {},
        url: form.data().userUrl,
      };
      dataUsers.data[form.data().userParam] = assignUser;
      dataUsers.data[form.data().userParamUnassign] = unassignUser;

      // Queue
      queue.add(dataUsers);
      addedToQueue = true;
    }

    // Build an array of id's to assign and an array to unassign
    const assignUserGroup = [];
    const unassignUserGroup = [];

    $.each(members.userGroups, function(name, value) {
      if (value == 1) {
        assignUserGroup.push(name);
      } else {
        unassignUserGroup.push(name);
      }
    });

    if (assignUserGroup.length > 0 || unassignUserGroup.length > 0) {
      const dataUserGroups = {
        data: {},
        url: form.data().userGroupsUrl,
      };
      dataUserGroups.data[form.data().userGroupsParam] = assignUserGroup;
      dataUserGroups.data[form.data().userGroupsParamUnassign] =
        unassignUserGroup;

      // Queue
      queue.add(dataUserGroups);
      addedToQueue = true;
    }

    if (!addedToQueue) {
      XiboDialogClose();
    } else {
      // Start the queue
      queue.start();
    }
  },

  // Callback for the media form
  mediaDisplayGroupFormCallBack: function() {
    const container = $('#FileAssociationsAssign');
    if (container.data().media == undefined) {
      container.data().media = {};
    }

    // Get starting items
    let includedItems = [];
    $('#FileAssociationsSortable').find('[data-media-id]').each((_i, el) => {
      includedItems.push($(el).data('mediaId'));
    });

    const mediaTable = $('#mediaAssignments').DataTable({
      language: dataTablesLanguage,
      serverSide: true, stateSave: true,
      searchDelay: 3000,
      order: [[0, 'asc']],
      filter: false,
      ajax: {
        url: $('#mediaAssignments').data().url,
        data: function(d) {
          $.extend(d, $('#mediaAssignments').closest('.XiboGrid')
            .find('.FilterDiv form').serializeObject());
        },
      },
      columns: [
        {data: 'name'},
        {data: 'mediaType'},
        {
          sortable: false,
          data: function(data, type, row, meta) {
            if (type != 'display') {
              return '';
            }

            // If media id is already added to the container
            // Create span with disabled
            if (includedItems.indexOf(data.mediaId) != -1) {
              // Create a disabled span
              return '<a href="#" class="assignItem disabled">' +
                '<span class="fa fa-plus hidden"></a>';
            } else {
              // Create a click-able span
              return '<a href="#" class="assignItem">' +
                '<span class="fa fa-plus"></a>';
            }
          },
        },
      ],
    });

    mediaTable.on('draw', function(e, settings) {
      dataTableDraw(e, settings);

      // Clicky on the +spans
      $('.assignItem:not(.disabled)', '#mediaAssignments')
        .on('click', function(ev) {
          const $target = $(ev.currentTarget);

          // Get the row that this is in.
          const data = mediaTable.row($target.closest('tr')).data();

          // Append to our media list
          container.data().media[data.mediaId] = 1;

          // Add to aux array
          includedItems.push(data.mediaId);

          // Disable add button
          $target.parents('tr').addClass('disabled');
          // Hide plus button
          $target.hide();

          // Construct a new list item for the lower list and append it.
          const newItem = $('<li/>', {
            text: data.name,
            'data-media-id': data.mediaId,
            class: 'btn btn-sm btn-white',
          });

          newItem.appendTo('#FileAssociationsSortable');

          // Add a span to that new item
          $('<span/>', {
            class: 'fa fa-minus ml-1',
          }).appendTo(newItem);
        });
    });
    mediaTable.on('processing.dt', dataTableProcessing);

    // Make our little list sortable
    $('#FileAssociationsSortable').sortable();

    // Bind to the existing items in the list
    $('#FileAssociationsSortable').on('click', 'li span', function(ev) {
      const $target = $(ev.currentTarget);

      const mediaId = $target.parent().data().mediaId;
      container.data().media[mediaId] = 0;
      $target.parent().remove();

      // Remove from aux array
      includedItems = includedItems.filter((item) => item != mediaId);

      // Reload table
      mediaTable.ajax.reload();
    });

    // Bind to the filter
    $('#mediaAssignments').closest('.XiboGrid')
      .find('.FilterDiv input, .FilterDiv select').on('change', function() {
        mediaTable.ajax.reload();
      });
  },

  mediaAssignSubmit: function() {
    // Collect our media
    const container = $('#FileAssociationsAssign');

    // Build an array of id's to assign and an array to unassign
    const assign = [];
    const unassign = [];

    $.each(container.data().media, function(name, value) {
      if (value == 1) {
        assign.push(name);
      } else {
        unassign.push(name);
      }
    });

    assignMediaToCampaign(container.data().url, assign, unassign);
  },

  // Callback for the media form
  layoutFormCallBack: function() {
    const container = $('#FileAssociationsAssign');
    if (container.data().layout == undefined) {
      container.data().layout = {};
    }

    const layoutTable = $('#layoutAssignments').DataTable({
      language: dataTablesLanguage,
      serverSide: true, stateSave: true,
      searchDelay: 3000,
      order: [[0, 'asc']],
      filter: false,
      ajax: {
        url: $('#layoutAssignments').data().url,
        data: function(d) {
          $.extend(
            d,
            $('#layoutAssignments').closest('.XiboGrid')
              .find('.FilterDiv form').serializeObject(),
          );
        },
      },
      columns: [
        {data: 'layout'},
        {
          sortable: false,
          data: function(data, type, row, meta) {
            if (type != 'display') {
              return '';
            }

            // Create a click-able span
            return '<a href="#" class="assignItem">' +
              '<span class="fa fa-plus"></a>';
          },
        },
      ],
    });

    layoutTable.on('draw', function(e, settings) {
      dataTableDraw(e, settings);

      // Clicky on the +spans
      $('.assignItem', '#layoutAssignments').on('click', function(ev) {
        // Get the row that this is in.
        const data = layoutTable.row($(ev.currentTarget).closest('tr')).data();

        // Append to our layout list
        container.data().layout[data.layoutId] = 1;

        // Construct a new list item for the lower list and append it.
        const newItem = $('<li/>', {
          text: data.layout,
          'data-layout-id': data.layoutId,
          class: 'btn btn-sm btn-white',
        });

        newItem.appendTo('#FileAssociationsSortable');

        // Add a span to that new item
        $('<span/>', {
          class: 'fa fa-minus',
          click: function(ev) {
            container.data().layout[$(ev.currentTarget)
              .parent().data().layoutId] = 0;
            $(ev.currentTarget).parent().remove();
          },
        }).appendTo(newItem);
      });
    });
    layoutTable.on('processing.dt', dataTableProcessing);

    // Make our little list sortable
    $('#FileAssociationsSortable').sortable();

    // Bind to the existing items in the list
    $('#FileAssociationsSortable').find('li span').on('click', function(ev) {
      container.data().layout[$(ev.currentTarget).parent().data().layoutId] = 0;
      $(ev.currentTarget).parent().remove();
    });

    // Bind to the filter
    $('#layoutAssignments').closest('.XiboGrid')
      .find('.FilterDiv input, .FilterDiv select').on('change', function() {
        layoutTable.ajax.reload();
      });
  },

  layoutAssignSubmit: function() {
    // Collect our layout
    const container = $('#FileAssociationsAssign');

    // Build an array of id's to assign and an array to unassign
    const assign = [];
    const unassign = [];

    $.each(container.data().layout, function(name, value) {
      if (value == 1) {
        assign.push(name);
      } else {
        unassign.push(name);
      }
    });

    assignLayoutToCampaign(container.data().url, assign, unassign);
  },

  userProfileEditFormOpen: function() {
    $('#qRCode').addClass('d-none');
    $('#recoveryButtons').addClass('d-none');
    $('#recoveryCodes').addClass('d-none');

    $('#twoFactorTypeId').on('change', function(e) {
      e.preventDefault();
      if (
        $('#twoFactorTypeId').val() == 2 &&
        $('#userEditProfileForm').data().currentuser != 2
      ) {
        $.ajax({
          url: $('#userEditProfileForm').data().setup,
          type: 'GET',
          beforeSend: function() {
            $('#qr').addClass('fa fa-spinner fa-spin loading-icon');
          },
          success: function(response) {
            const qRCode = response.data.qRUrl;
            $('#qrImage').attr('src', qRCode);
          },
          complete: function() {
            $('#qr').removeClass('fa fa-spinner fa-spin loading-icon');
          },
        });
        $('#qRCode').removeClass('d-none');
      } else {
        $('#qRCode').addClass('d-none');
      }

      if ($('#twoFactorTypeId').val() == 0) {
        $('#recoveryButtons').addClass('d-none');
        $('#recoveryCodes').addClass('d-none');
      }

      if (
        $('#userEditProfileForm').data().currentuser != 0 &&
        $('#twoFactorTypeId').val() != 0
      ) {
        $('#recoveryButtons').removeClass('d-none');
      }
    });

    if ($('#userEditProfileForm').data().currentuser != 0) {
      $('#recoveryButtons').removeClass('d-none');
    }
    let generatedCodes = '';

    $('#generateCodesBtn').on('click', function(e) {
      $('#codesList').html('');
      $('#recoveryCodes').removeClass('d-none');
      $('.recBtn').attr('disabled', true).addClass('disabled');
      generatedCodes = '';

      $.ajax({
        url: $('#userEditProfileForm').data().generate,
        async: false,
        type: 'GET',
        beforeSend: function() {
          $('#codesList').removeClass('card')
            .addClass('fa fa-spinner fa-spin loading-icon');
        },
        success: function(response) {
          generatedCodes = JSON.parse(response.data.codes);
          $('#recoveryCodes').addClass('d-none');
          $('.recBtn').attr('disabled', false).removeClass('disabled');
          $('#showCodesBtn').trigger('click');
        },
        complete: function() {
          $('#codesList').removeClass('fa fa-spinner fa-spin loading-icon');
        },
      });
    });

    $('#showCodesBtn').on('click', function(e) {
      $('.recBtn').attr('disabled', true).addClass('disabled');
      $('#codesList').html('');
      $('#recoveryCodes').toggleClass('d-none');
      let codesList = [];

      $.ajax({
        url: $('#userEditProfileForm').data().show,
        type: 'GET',
        data: {
          generatedCodes: generatedCodes,
        },
        success: function(response) {
          if (generatedCodes != '') {
            codesList = generatedCodes;
          } else {
            codesList = response.data.codes;
          }

          $('#twoFactorRecoveryCodes').val(JSON.stringify(codesList));
          $.each(codesList, function(index, value) {
            $('#codesList').append(value + '<br/>');
          });
          $('#codesList').addClass('card');
          $('.recBtn').attr('disabled', false).removeClass('disabled');
        },
      });
    });
  },

  tagsWithValues: function(formId) {
    $('#tagValue, label[for="tagValue"], #tagValueRequired').addClass('d-none');
    $('#tagValueContainer').hide();

    let tag;
    let tagWithOption = '';
    let tagN = '';
    let tagV = '';
    let tagOptions = [];
    let tagIsRequired = 0;

    const formSelector =
      '#' + formId + ' input#tags' + ', #' + formId + ' input#tagsToAdd';

    $(formSelector).on('beforeItemAdd', function(event) {
      $('#tagValue').html('');
      $('#tagValueInput').val('');
      tag = event.item;
      tagOptions = [];
      tagIsRequired = 0;
      tagN = tag.split('|')[0];
      tagV = tag.split('|')[1];

      if ($(formSelector).val().indexOf(tagN) >= 0) {
        // if we entered a Tag that already exists there are two options
        // exists without value and entered without value
        //   handled automatically allowDuplicates = false
        // as we allow entering Tags with value
        //    we need additional handling for that

        // go through tagsinput items and return the
        // one that matches Tag name about to be added
        const item = $(formSelector).tagsinput('items').filter((item) => {
          return item.split('|')[0].toLowerCase() === tagN.toLowerCase();
        });

        // remove the existing Tag from tagsinput before adding the new one
        $(formSelector).tagsinput('remove', item.toString());
      }

      if ($(formSelector).val().indexOf(tagN) === -1 && tagV === undefined) {
        $.ajax({
          url: $('form#' + formId).data().gettag,
          type: 'GET',
          data: {
            name: tagN,
          },
          beforeSend: function() {
            $('#loadingValues').addClass('fa fa-spinner fa-spin loading-icon');
          },
          success: function(response) {
            if (response.success) {
              if (response.data.tag != null) {
                tagOptions = JSON.parse(response.data.tag.options);
                tagIsRequired = response.data.tag.isRequired;

                if (tagOptions != null && tagOptions != []) {
                  $('#tagValue, label[for="tagValue"]').removeClass('d-none');

                  if ($('#tagValue option[value=""]').length <= 0) {
                    $('#tagValue')
                      .append($('<option></option>')
                        .attr('value', '')
                        .text(''));
                  }

                  $.each(tagOptions, function(key, value) {
                    if (
                      $('#tagValue option[value=' + value + ']').length <= 0
                    ) {
                      $('#tagValue')
                        .append($('<option></option>')
                          .attr('value', value)
                          .text(value));
                    }
                  });

                  $('#tagValue').trigger('focus');
                } else {
                  // existing Tag without specified options (values)
                  $('#tagValueContainer').show();

                  // if the isRequired flag is set to 0 change
                  // the helpText to be more user friendly.
                  if (tagIsRequired === 0) {
                    $('#tagValueInput').parent().find('span.help-block')
                      .text(translations.tagInputValueHelpText);
                  } else {
                    $('#tagValueInput').parent().find('span.help-block')
                      .text(translations.tagInputValueRequiredHelpText);
                  }

                  $('#tagValueInput').trigger('focus');
                }
              } else {
                // new Tag
                $('#tagValueContainer').show();
                $('#tagValueInput').trigger('focus');

                // isRequired flag is set to 0 (new Tag) change
                // the helpText to be more user friendly.
                $('#tagValueInput').parent().find('span.help-block')
                  .text(translations.tagInputValueHelpText);
              }
            }
          },
          complete: function() {
            $('#loadingValues').removeClass(
              'fa fa-spinner fa-spin loading-icon',
            );
          },
          error: function(jqXHR, textStatus, errorThrown) {
            console.error(jqXHR, textStatus, errorThrown);
          },
        });
      }
    });

    $(formSelector).on('itemAdded', function(event) {
      if (tagOptions != null && tagOptions != []) {
        $('#tagValue').trigger('focus');
      }
    });

    $(formSelector).on('itemRemoved', function(event) {
      if (tagN === event.item) {
        $('#tagValueRequired, label[for="tagValue"]').addClass('d-none');
        $('.save-button').prop('disabled', false);
        $('#tagValue').html('').addClass('d-none');
        $('#tagValueInput').val('');
        $('#tagValueContainer').hide();
        tagN = '';
      } else if ($('.save-button').is(':disabled')) {
        // do nothing with jQuery
      } else {
        $('#tagValue').html('').addClass('d-none');
        $('#tagValueInput').val('');
        $('#tagValueContainer').hide();
        $('label[for="tagValue"]').addClass('d-none');
      }
    });

    $('#tagValue').on('change', function(e) {
      e.preventDefault();
      tagWithOption = tagN + '|' + $(e.currentTarget).val();

      // additional check, helpful for multi tagging.
      if (tagN != '') {
        if (
          tagIsRequired === 0 ||
          (tagIsRequired === 1 && $(e.currentTarget).val() !== '')
        ) {
          $(formSelector).tagsinput('add', tagWithOption);
          $(formSelector).tagsinput('remove', tagN);
          $('#tagValue').html('').addClass('d-none');
          $('#tagValueRequired, label[for="tagValue"]').addClass('d-none');
          $('.save-button').prop('disabled', false);
        } else {
          $('#tagValueRequired').removeClass('d-none');
          $('#tagValue').trigger('focus');
        }
      }
    });

    $('#tagValue').on('blur', function(ev) {
      if ($(ev.currentTarget).val() === '' && tagIsRequired === 1) {
        $('#tagValueRequired').removeClass('d-none');
        $('#tagValue').trigger('focus');
        $('.save-button').prop('disabled', true);
      } else {
        $('#tagValue').html('').addClass('d-none');
        $('label[for="tagValue"]').addClass('d-none');
      }
    });

    $('#tagValueInput').on('keypress focusout', function(event) {
      if ((event.keyCode === 13 || event.type === 'focusout') && tagN != '') {
        event.preventDefault();
        const tagInputValue = $(event.currentTarget).val();
        tagWithOption =
          (tagInputValue !== '') ? tagN + '|' + tagInputValue : tagN;

        if (
          tagIsRequired === 0 || (tagIsRequired === 1 && tagInputValue !== '')
        ) {
          $(formSelector).tagsinput('add', tagWithOption);
          // remove only if we have value (otherwise it would be left empty)
          if (tagInputValue !== '') {
            $(formSelector).tagsinput('remove', tagN);
          }

          $('#tagValueInput').val('');
          $('#tagValueContainer').hide();
          $('#tagValueRequired').addClass('d-none');
          $('.save-button').prop('disabled', false);
        } else {
          $('#tagValueContainer').show();
          $('#tagValueRequired').removeClass('d-none');
          $('#tagValueInput').trigger('focus');
        }
      }
    });
  },

  /**
  * Called when the ACL form is opened on Users/User Groups
  * @param dialog
  */
  featureAclFormOpen: function(dialog) {
    // Start everything collapsed.
    $(dialog).find('tr.feature-row').hide();

    // Bind to clicking on the feature header cells
    $(dialog).find('td.feature-group-header-cell').on('click', function(ev) {
      // Toggle state
      const $header = $(ev.currentTarget);
      const isOpen = $header.hasClass('open');

      if (isOpen) {
        // Make closed
        $header.find('.feature-group-description').show();
        $header.find('i.fa').removeClass('fa-arrow-circle-up')
          .addClass('fa fa-arrow-circle-down');
        $header.closest('tbody.feature-group').find('tr.feature-row').hide();
        $header.removeClass('open').addClass('closed');
      } else {
        // Make open
        $header.find('.feature-group-description').hide();
        $header.find('i.fa').removeClass('fa-arrow-circle-down')
          .addClass('fa fa-arrow-circle-up');
        $header.closest('tbody.feature-group').find('tr.feature-row').show();
        $header.removeClass('closed').addClass('open');
      }
    }).each(function(index, el) {
      // Set the initial state of the 3 way checkboxes
      setFeatureGroupCheckboxState($(el));
    });

    // Bind to checkbox change event
    $(dialog).find('input[name=\'features[]\']').on('click', function(ev) {
      setFeatureGroupCheckboxState($(ev.currentTarget));
    });

    // Bind to group checkboxes to check/uncheck all below.
    $(dialog).find('input.feature-select-all').on('click', function(ev) {
      // Force this down to all child checkboxes
      $(ev.currentTarget)
        .closest('tbody.feature-group')
        .find('input[name=\'features[]\']')
        .prop('checked', $(ev.currentTarget).is(':checked'));
    });
  },

  userApprovedApplicationsFormOpen: function(dialog) {
    $('.revokeAccess').on('click', function(e) {
      const $this = $(e.currentTarget);
      const clientKey = $this.data('applicationKey');
      const userId = $this.data('applicationUser');

      $.ajax({
        url: revokeApplicationAccess.replace(':id', clientKey)
          .replace(':userId', userId),
        type: 'DELETE',
        success: function(res) {
          if (res.success) {
            $this.closest('tr').remove();
            toastr.success(res.message);
          } else {
            toastr.error(res.message);
          }
        },
      });
    });
  },

  folderMoveSubmit: function() {
    XiboFormSubmit($('#moveFolderForm'), null, function(xhr, form) {
      if (xhr.success) {
        $('#container-folder-tree').jstree(true).refresh();
      }
    });
  },

  validateForm: function(
    form,
    container,
    options,
  ) {
    const defaultOptions = {
      submitHandler: options.submitHandler,
      errorElement: 'span',
      // Ignore the date picker helpers
      ignore: '.datePickerHelper',
      errorPlacement: function(error, element) {
        if ($(element).hasClass('dateControl')) {
          // Places the error label date controller
          error.insertAfter(element.parent());
        } else if (
          $(element).siblings('.validation-error-container').length > 0
        ) {
          error.appendTo($(element).siblings('.validation-error-container'));
        } else {
          // Places the error label after the invalid element
          error.insertAfter(element);
        }
      },
      highlight: function(element) {
        $(element).closest('.form-group')
          .removeClass('has-success')
          .addClass('has-error');
      },
      success: function(element) {
        $(element).closest('.form-group')
          .removeClass('has-error')
          .addClass('has-success');
      },
      invalidHandler: function(event, validator) {
        // Mark non active tabs with error if they have an invalid element
        $('.nav-item a.nav-link').removeClass('has-error');
        validator.errorList.forEach((error) => {
          const element = error.element;
          $(element).parents('.tab-pane').each((_id, tab) => {
            $('.nav-item a.nav-link[href="#' +
              $(tab).attr('id') + '"]:not(.active)')
              .addClass('has-error');
          });
        });

        // Remove the spinner
        $(this).closest('.modal-dialog').find('.saving').remove();
        // https://github.com/xibosignage/xibo/issues/1589
        $(this).closest('.modal-dialog').find('.save-button')
          .removeClass('disabled');
      },
    };

    // Merge options with defaults
    Object.assign(
      defaultOptions,
      options,
    );

    // Init validator
    const validatorObj = form.validate(defaultOptions);

    // If we are in a modal, validate on tab change
    container.find('.nav-link').on('shown.bs.tab', () => {
      validatorObj.form();
    });
  },
};
