/* eslint-disable func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-empty */ /* global Issuable */ import $ from 'jquery'; import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import IssuableBulkUpdateActions from '~/issuable/issuable_bulk_update_actions'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { sprintf, __ } from '~/locale'; import CreateLabelDropdown from './create_label_dropdown'; export default class LabelsSelect { constructor(els, options = {}) { const _this = this; let $els = $(els); if (!els) { $els = $('.js-label-select'); } $els.each((i, dropdown) => { const $dropdown = $(dropdown); const $dropdownContainer = $dropdown.closest('.labels-filter'); const namespacePath = $dropdown.data('namespacePath'); const projectPath = $dropdown.data('projectPath'); const issueUpdateURL = $dropdown.data('issueUpdate'); let selectedLabel = $dropdown.data('selected'); if (selectedLabel != null && !$dropdown.hasClass('js-multiselect')) { selectedLabel = selectedLabel.split(','); } const showNo = $dropdown.data('showNo'); const showAny = $dropdown.data('showAny'); const showMenuAbove = $dropdown.data('showMenuAbove'); const defaultLabel = $dropdown.data('defaultLabel') || __('Label'); const abilityName = $dropdown.data('abilityName'); const $selectbox = $dropdown.closest('.selectbox'); const $block = $selectbox.closest('.block'); const $form = $dropdown.closest('form, .js-issuable-update'); const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); const $value = $block.find('.value'); const $loading = $block.find('.block-loading').addClass('gl-display-none'); const fieldName = $dropdown.data('fieldName'); let initialSelected = $selectbox .find(`input[name="${$dropdown.data('fieldName')}"]`) .map(function () { return this.value; }) .get(); const scopedLabels = $dropdown.data('scopedLabels'); const { handleClick } = options; if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) { new CreateLabelDropdown( $dropdown.closest('.dropdown').find('.dropdown-new-label'), namespacePath, projectPath, ); } const saveLabelData = function () { const selected = $dropdown .closest('.selectbox') .find(`input[name='${fieldName}']`) .map(function () { return this.value; }) .get(); if (isEqual(initialSelected, selected)) return; initialSelected = selected; const data = {}; data[abilityName] = {}; data[abilityName].label_ids = selected; if (!selected.length) { data[abilityName].label_ids = ['']; } $loading.removeClass('gl-display-none'); $dropdown.trigger('loading.gl.dropdown'); axios .put(issueUpdateURL, data) .then(({ data }) => { let template; $loading.addClass('gl-display-none'); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); data.issueUpdateURL = issueUpdateURL; let labelCount = 0; if (data.labels.length && issueUpdateURL) { template = LabelsSelect.getLabelTemplate({ labels: sortBy(data.labels, 'title'), issueUpdateURL, enableScopedLabels: scopedLabels, }); labelCount = data.labels.length; // EE Specific if (IS_EE) { /** * For Scoped labels, the last label selected with the * same key will be applied to the current issuable. * * If these are the labels - priority::1, priority::2; and if * we apply them in the same order, only priority::2 will stick * with the issuable. * * In the current dropdown implementation, we keep track of all * the labels selected via a hidden DOM element. Since a User * can select priority::1 and priority::2 at the same time, the * DOM will have 2 hidden input and the dropdown will show both * the items selected but in reality server only applied * priority::2. * * We find all the labels then find all the labels server accepted * and then remove the excess ones. */ const toRemoveIds = Array.from( $form.find(`input[type="hidden"][name="${fieldName}"]`), ) .map((el) => el.value) .map(Number); data.labels.forEach((label) => { const index = toRemoveIds.indexOf(label.id); toRemoveIds.splice(index, 1); }); toRemoveIds.forEach((id) => { $form .find(`input[type="hidden"][name="${fieldName}"][value="${id}"]`) .last() .remove(); }); } } else { template = `${__('None')}`; } $value.removeAttr('style').html(template); $sidebarCollapsedValue.text(labelCount); $('.has-tooltip', $value).tooltip({ container: 'body', }); }) .catch(() => createAlert({ message: __('Error saving label update.'), }), ); }; initDeprecatedJQueryDropdown($dropdown, { showMenuAbove, data(term, callback) { const labelUrl = $dropdown.attr('data-labels'); axios .get(labelUrl) .then((res) => { let { data } = res; if ($dropdown.hasClass('js-extra-options')) { const extraData = []; if (showNo) { extraData.unshift({ id: 0, title: __('No label'), }); } if (showAny) { extraData.unshift({ isAny: true, title: __('Any label'), }); } if (extraData.length) { extraData.push({ type: 'divider' }); data = extraData.concat(data); } } callback(data); if (showMenuAbove) { $dropdown.data('deprecatedJQueryDropdown').positionMenuAbove(); } }) .catch(() => createAlert({ message: __('Error fetching labels.'), }), ); }, renderRow(label) { let colorEl; const selectedClass = []; const removesAll = label.id <= 0 || label.id == null; if ($dropdown.hasClass('js-filter-bulk-update')) { const indeterminate = $dropdown.data('indeterminate') || []; const marked = $dropdown.data('marked') || []; if (indeterminate.indexOf(label.id) !== -1) { selectedClass.push('is-indeterminate'); } if (marked.indexOf(label.id) !== -1) { // Remove is-indeterminate class if the item will be marked as active const i = selectedClass.indexOf('is-indeterminate'); if (i !== -1) { selectedClass.splice(i, 1); } selectedClass.push('is-active'); } } else { if (this.id(label)) { const dropdownValue = this.id(label).toString().replace(/'/g, "\\'"); if ( $form.find( `input[type='hidden'][name='${this.fieldName}'][value='${dropdownValue}']`, ).length ) { selectedClass.push('is-active'); } } if (this.multiSelect && removesAll) { selectedClass.push('dropdown-clear-active'); } } if (label.color) { colorEl = ``; } else { colorEl = ''; } const linkEl = document.createElement('a'); linkEl.href = '#'; // We need to identify which items are actually labels if (label.id) { const selectedLayoutClasses = ['d-flex', 'flex-row', 'text-break-word']; selectedClass.push('label-item', ...selectedLayoutClasses); linkEl.dataset.labelId = label.id; } linkEl.className = selectedClass.join(' '); // eslint-disable-next-line no-unsanitized/property linkEl.innerHTML = `${colorEl} ${escape(label.title)}`; const listItemEl = document.createElement('li'); listItemEl.appendChild(linkEl); return listItemEl; }, search: { fields: ['title'], }, selectable: true, filterable: true, selected: $dropdown.data('selected') || [], toggleLabel(selected, el) { const $dropdownParent = $dropdown.parent(); const $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); const isSelected = el !== null ? el.hasClass('is-active') : false; const title = selected ? selected.title : null; const selectedLabels = this.selected; if ($dropdownInputField.length && $dropdownInputField.val().length) { $dropdownParent.find('.dropdown-input-clear').trigger('click'); } if (selected && selected.id === 0) { this.selected = []; return __('No label'); } else if (isSelected) { this.selected.push(title); } else if (!isSelected && title) { const index = this.selected.indexOf(title); this.selected.splice(index, 1); } if (selectedLabels.length === 1) { return selectedLabels; } else if (selectedLabels.length) { return sprintf(__('%{firstLabel} +%{labelCount} more'), { firstLabel: selectedLabels[0], labelCount: selectedLabels.length - 1, }); } return defaultLabel; }, fieldName: $dropdown.data('fieldName'), id(label) { if (label.id <= 0) return label.title; if ($dropdown.hasClass('js-issuable-form-dropdown')) { return label.id; } if ($dropdown.hasClass('js-filter-submit') && label.isAny == null) { return label.title; } return label.id; }, hidden() { const page = $('body').attr('data-page'); const isIssueIndex = page === 'projects:issues:index'; const isMRIndex = page === 'projects:merge_requests:index'; $selectbox.hide(); // display:block overrides the hide-collapse rule $value.removeAttr('style'); if ($dropdown.hasClass('js-issuable-form-dropdown')) { return; } if ( $('html') .attr('class') .match(/issue-boards-page|epic-boards-page/) ) { return; } if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { $dropdown.closest('form').submit(); } else { if (!$dropdown.hasClass('js-filter-bulk-update')) { saveLabelData(); $dropdown.data('deprecatedJQueryDropdown').clearMenu(); } } } }, multiSelect: $dropdown.hasClass('js-multiselect'), vue: false, clicked(clickEvent) { const { e, isMarking } = clickEvent; const label = clickEvent.selectedObj; const page = $('body').attr('data-page'); const isIssueIndex = page === 'projects:issues:index'; const isMRIndex = page === 'projects:merge_requests:index'; if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) { $dropdown.parent().find('.dropdown-clear-active').removeClass('is-active'); } if ($dropdown.hasClass('js-issuable-form-dropdown')) { return; } if ($dropdown.hasClass('js-filter-bulk-update')) { _this.enableBulkLabelDropdown(); _this.setDropdownData($dropdown, isMarking, label.id); return; } if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { if (!$dropdown.hasClass('js-multiselect')) { selectedLabel = label.title; return Issuable.filterResults($dropdown.closest('form')); } } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); } else if (handleClick) { e.preventDefault(); handleClick(label); } else { if ($dropdown.hasClass('js-multiselect')) { } else { return saveLabelData(); } } }, preserveContext: true, }); // Set dropdown data _this.setOriginalDropdownData($dropdownContainer, $dropdown); }); this.bindEvents(); } static getLabelTemplate(tplData) { // We could use ES6 template string here // and properly indent markup for readability // but that also introduces unintended white-space // so best approach is to use traditional way of // concatenation // see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays const linkOpenTag = '?label_name[]=<%- encodeURIComponent(label.title) %>" class="gl-link gl-label-link has-tooltip" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>">'; const labelTemplate = template( [ '', linkOpenTag, '', '<%- label.title %>', '', '', '', ].join(''), ); const labelTextClass = ({ label, escapeStr }) => { return escapeStr( label.text_color === '#FFFFFF' ? 'gl-label-text-light' : 'gl-label-text-dark', ); }; const rightLabelTextClass = ({ label, escapeStr }) => { return escapeStr(label.text_color === '#333333' ? labelTextClass({ label, escapeStr }) : ''); }; const scopedLabelTemplate = template( [ '', linkOpenTag, '', '<%- label.title.slice(0, label.title.lastIndexOf("::")) %>', '', '', '<%- label.title.slice(label.title.lastIndexOf("::") + 2) %>', '', '', '', ].join(''), ); const tooltipTitleTemplate = template( [ '<% if (isScopedLabel(label) && enableScopedLabels) { %>', "Scoped label", '
', '<%= escapeStr(label.description) %>', '<% } else { %>', '<%= escapeStr(label.description) %>', '<% } %>', ].join(''), ); const tpl = template( [ '<% labels.forEach(function(label){ %>', '<% if (isScopedLabel(label) && enableScopedLabels) { %>', '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, labelTextClass, rightLabelTextClass, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', '<% } else { %>', '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, labelTextClass, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>', '<% } %>', '<% }); %>', ].join(''), ); return tpl({ ...tplData, labelTemplate, labelTextClass, rightLabelTextClass, scopedLabelTemplate, tooltipTitleTemplate, isScopedLabel, escapeStr: escape, }); } bindEvents() { return $('body').on( 'change', '.issuable-list input[type="checkbox"]', this.onSelectCheckboxIssue, ); } // eslint-disable-next-line class-methods-use-this onSelectCheckboxIssue() { if ($('.issuable-list input[type="checkbox"]:checked').length) { return; } return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text(__('Label')); } // eslint-disable-next-line class-methods-use-this enableBulkLabelDropdown() { IssuableBulkUpdateActions.willUpdateLabels = true; } // eslint-disable-next-line class-methods-use-this setDropdownData($dropdown, isMarking, labelId) { let userCheckedIds = $dropdown.data('user-checked') || []; let userUncheckedIds = $dropdown.data('user-unchecked') || []; if (isMarking) { userCheckedIds = union(userCheckedIds, [labelId]); userUncheckedIds = difference(userUncheckedIds, [labelId]); } else { userUncheckedIds = union(userUncheckedIds, [labelId]); userCheckedIds = difference(userCheckedIds, [labelId]); } $dropdown.data('user-checked', userCheckedIds); $dropdown.data('user-unchecked', userUncheckedIds); } // eslint-disable-next-line class-methods-use-this setOriginalDropdownData($container, $dropdown) { const labels = []; $container.find('[name="label_name[]"]').map(function () { return labels.push(this.value); }); $dropdown.data('marked', labels); } }