/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, dot-notation, no-empty */
/* global Issuable */
/* global ListLabel */
import $ from 'jquery';
import { difference, isEqual, escape, sortBy, template, union } from 'lodash';
import { sprintf, s__, __ } from './locale';
import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import CreateLabelDropdown from './create_label';
import { deprecatedCreateFlash as flash } from './flash';
import ModalStore from './boards/stores/modal_store';
import boardsStore from './boards/stores/boards_store';
import { isScopedLabel } from '~/lib/utils/common_utils';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { fixTitle } from '~/tooltips';
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 $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
const $value = $block.find('.value');
const $dropdownMenu = $dropdown.parent().find('.dropdown-menu');
// eslint-disable-next-line no-jquery/no-fade
const $loading = $block.find('.block-loading').fadeOut();
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 = [''];
}
// eslint-disable-next-line no-jquery/no-fade
$loading.removeClass('hidden').fadeIn();
$dropdown.trigger('loading.gl.dropdown');
axios
.put(issueUpdateURL, data)
.then(({ data }) => {
let labelTooltipTitle;
let template;
// eslint-disable-next-line no-jquery/no-fade
$loading.fadeOut();
$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 issueable.
*
* 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);
if (data.labels.length) {
let labelTitles = data.labels.map(label => label.title);
if (labelTitles.length > 5) {
labelTitles = labelTitles.slice(0, 5);
labelTitles.push(
sprintf(s__('Labels|and %{count} more'), { count: data.labels.length - 5 }),
);
}
labelTooltipTitle = labelTitles.join(', ');
} else {
labelTooltipTitle = __('Labels');
}
$sidebarLabelTooltip.attr('title', labelTooltipTitle);
fixTitle($sidebarLabelTooltip);
$('.has-tooltip', $value).tooltip({
container: 'body',
});
})
.catch(() => flash(__('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(() => flash(__('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(' ');
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').hasClass('issue-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: $dropdown.hasClass('js-issue-board-sidebar'),
clicked(clickEvent) {
const { $el, e, isMarking } = clickEvent;
const label = clickEvent.selectedObj;
const fadeOutLoader = () => {
// eslint-disable-next-line no-jquery/no-fade
$loading.fadeOut();
};
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;
}
let boardsModel;
if ($dropdown.closest('.add-issues-modal').length) {
boardsModel = ModalStore.store.filter;
}
if (boardsModel) {
if (label.isAny) {
boardsModel['label_name'] = [];
} else if ($el.hasClass('is-active')) {
boardsModel['label_name'].push(label.title);
}
e.preventDefault();
return;
} else 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 ($dropdown.hasClass('js-issue-board-sidebar')) {
if ($el.hasClass('is-active')) {
boardsStore.detail.issue.labels.push(
new ListLabel({
id: label.id,
title: label.title,
color: label.color,
textColor: '#fff',
}),
);
} else {
let { labels } = boardsStore.detail.issue;
labels = labels.filter(selectedLabel => selectedLabel.id !== label.id);
boardsStore.detail.issue.labels = labels;
}
// eslint-disable-next-line no-jquery/no-fade
$loading.fadeIn();
const oldLabels = boardsStore.detail.issue.labels;
boardsStore.detail.issue
.update($dropdown.attr('data-issue-update'))
.then(() => {
if (isScopedLabel(label)) {
const prevIds = oldLabels.map(label => label.id);
const newIds = boardsStore.detail.issue.labels.map(label => label.id);
const differentIds = prevIds.filter(x => !newIds.includes(x));
$dropdown.data('marked', newIds);
$dropdownMenu
.find(differentIds.map(id => `[data-label-id="${id}"]`).join(','))
.removeClass('is-active');
}
})
.then(fadeOutLoader)
.catch(fadeOutLoader);
} else if (handleClick) {
e.preventDefault();
handleClick(label);
} else {
if ($dropdown.hasClass('js-multiselect')) {
} else {
return saveLabelData();
}
}
},
opened() {
if ($dropdown.hasClass('js-issue-board-sidebar')) {
const previousSelection = $dropdown.attr('data-selected');
this.selected = previousSelection ? previousSelection.split(',') : [];
$dropdown.data('deprecatedJQueryDropdown').updateLabel();
}
},
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', '.selected-issuable', this.onSelectCheckboxIssue);
}
// eslint-disable-next-line class-methods-use-this
onSelectCheckboxIssue() {
if ($('.selected-issuable: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);
}
}