Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-12-07 18:15:03 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-12-07 18:15:03 +0300
commit3a0f6ebaa9a80e34c09f599a624f6e4520009ef1 (patch)
tree4560fa6bcba7d950595e906ee3286dd1e44642f1 /app/assets/javascripts/labels
parent6dd9e3644eea1a5c605a6a623cae1d53b156b9e5 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/labels')
-rw-r--r--app/assets/javascripts/labels/create_label.js131
-rw-r--r--app/assets/javascripts/labels/delete_label_modal.js16
-rw-r--r--app/assets/javascripts/labels/group_label_subscription.js76
-rw-r--r--app/assets/javascripts/labels/init_labels.js19
-rw-r--r--app/assets/javascripts/labels/label_manager.js146
-rw-r--r--app/assets/javascripts/labels/labels.js37
-rw-r--r--app/assets/javascripts/labels/labels_select.js515
-rw-r--r--app/assets/javascripts/labels/project_label_subscription.js77
8 files changed, 1017 insertions, 0 deletions
diff --git a/app/assets/javascripts/labels/create_label.js b/app/assets/javascripts/labels/create_label.js
new file mode 100644
index 00000000000..8c166158a44
--- /dev/null
+++ b/app/assets/javascripts/labels/create_label.js
@@ -0,0 +1,131 @@
+/* eslint-disable func-names */
+
+import $ from 'jquery';
+import Api from '~/api';
+import { humanize } from '~/lib/utils/text_utility';
+
+export default class CreateLabelDropdown {
+ constructor($el, namespacePath, projectPath) {
+ this.$el = $el;
+ this.namespacePath = namespacePath;
+ this.projectPath = projectPath;
+ this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown'));
+ this.$cancelButton = $('.js-cancel-label-btn', this.$el);
+ this.$newLabelField = $('#new_label_name', this.$el);
+ this.$newColorField = $('#new_label_color', this.$el);
+ this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el);
+ this.$addList = $('.js-add-list', this.$el);
+ this.$newLabelError = $('.js-label-error', this.$el);
+ this.$newLabelCreateButton = $('.js-new-label-btn', this.$el);
+ this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el);
+
+ this.$newLabelError.hide();
+ this.$newLabelCreateButton.disable();
+
+ this.addListDefault = this.$addList.is(':checked');
+
+ this.cleanBinding();
+ this.addBinding();
+ }
+
+ cleanBinding() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
+ this.$colorSuggestions.off('click');
+ // eslint-disable-next-line @gitlab/no-global-event-off
+ this.$newLabelField.off('keyup change');
+ // eslint-disable-next-line @gitlab/no-global-event-off
+ this.$newColorField.off('keyup change');
+ // eslint-disable-next-line @gitlab/no-global-event-off
+ this.$dropdownBack.off('click');
+ // eslint-disable-next-line @gitlab/no-global-event-off
+ this.$cancelButton.off('click');
+ // eslint-disable-next-line @gitlab/no-global-event-off
+ this.$newLabelCreateButton.off('click');
+ }
+
+ addBinding() {
+ const self = this;
+
+ this.$colorSuggestions.on('click', function (e) {
+ const $this = $(this);
+ self.addColorValue(e, $this);
+ });
+
+ this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this));
+ this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this));
+
+ this.$dropdownBack.on('click', this.resetForm.bind(this));
+
+ this.$cancelButton.on('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ self.resetForm();
+ self.$dropdownBack.trigger('click');
+ });
+
+ this.$newLabelCreateButton.on('click', this.saveLabel.bind(this));
+ }
+
+ addColorValue(e, $this) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.$newColorField.val($this.data('color')).trigger('change');
+ this.$colorPreview.css('background-color', $this.data('color')).parent().addClass('is-active');
+ }
+
+ enableLabelCreateButton() {
+ if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') {
+ this.$newLabelError.hide();
+ this.$newLabelCreateButton.enable();
+ } else {
+ this.$newLabelCreateButton.disable();
+ }
+ }
+
+ resetForm() {
+ this.$newLabelField.val('').trigger('change');
+
+ this.$newColorField.val('').trigger('change');
+
+ this.$addList.prop('checked', this.addListDefault);
+
+ this.$colorPreview.css('background-color', '').parent().removeClass('is-active');
+ }
+
+ saveLabel(e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ Api.newLabel(
+ this.namespacePath,
+ this.projectPath,
+ {
+ title: this.$newLabelField.val(),
+ color: this.$newColorField.val(),
+ },
+ (label) => {
+ this.$newLabelCreateButton.enable();
+
+ if (label.message) {
+ let errors;
+
+ if (typeof label.message === 'string') {
+ errors = label.message;
+ } else {
+ errors = Object.keys(label.message)
+ .map((key) => `${humanize(key)} ${label.message[key].join(', ')}`)
+ .join('<br/>');
+ }
+
+ this.$newLabelError.html(errors).show();
+ } else {
+ const addNewList = this.$addList.is(':checked');
+ this.$dropdownBack.trigger('click');
+ $(document).trigger('created.label', [label, addNewList]);
+ }
+ },
+ );
+ }
+}
diff --git a/app/assets/javascripts/labels/delete_label_modal.js b/app/assets/javascripts/labels/delete_label_modal.js
new file mode 100644
index 00000000000..cf7c9e7734f
--- /dev/null
+++ b/app/assets/javascripts/labels/delete_label_modal.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue';
+
+const mountDeleteLabelModal = (optionalProps) =>
+ new Vue({
+ render(h) {
+ return h(DeleteLabelModal, {
+ props: {
+ selector: '.js-delete-label-modal-button',
+ ...optionalProps,
+ },
+ });
+ },
+ }).$mount();
+
+export default (optionalProps = {}) => mountDeleteLabelModal(optionalProps);
diff --git a/app/assets/javascripts/labels/group_label_subscription.js b/app/assets/javascripts/labels/group_label_subscription.js
new file mode 100644
index 00000000000..ea69e6585e6
--- /dev/null
+++ b/app/assets/javascripts/labels/group_label_subscription.js
@@ -0,0 +1,76 @@
+import $ from 'jquery';
+import { __ } from '~/locale';
+import { fixTitle, hide } from '~/tooltips';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+
+const tooltipTitles = {
+ group: __('Unsubscribe at group level'),
+ project: __('Unsubscribe at project level'),
+};
+
+export default class GroupLabelSubscription {
+ constructor(container) {
+ const $container = $(container);
+ this.$dropdown = $container.find('.dropdown');
+ this.$subscribeButtons = $container.find('.js-subscribe-button');
+ this.$unsubscribeButtons = $container.find('.js-unsubscribe-button');
+
+ this.$subscribeButtons.on('click', this.subscribe.bind(this));
+ this.$unsubscribeButtons.on('click', this.unsubscribe.bind(this));
+ }
+
+ unsubscribe(event) {
+ event.preventDefault();
+
+ const url = this.$unsubscribeButtons.attr('data-url');
+ axios
+ .post(url)
+ .then(() => {
+ this.toggleSubscriptionButtons();
+ this.$unsubscribeButtons.removeAttr('data-url');
+ })
+ .catch(() =>
+ createFlash({
+ message: __('There was an error when unsubscribing from this label.'),
+ }),
+ );
+ }
+
+ subscribe(event) {
+ event.preventDefault();
+
+ const $btn = $(event.currentTarget);
+ const url = $btn.attr('data-url');
+
+ this.$unsubscribeButtons.attr('data-url', url);
+
+ axios
+ .post(url)
+ .then(() => GroupLabelSubscription.setNewTooltip($btn))
+ .then(() => this.toggleSubscriptionButtons())
+ .catch(() =>
+ createFlash({
+ message: __('There was an error when subscribing to this label.'),
+ }),
+ );
+ }
+
+ toggleSubscriptionButtons() {
+ this.$dropdown.toggleClass('hidden');
+ this.$subscribeButtons.toggleClass('hidden');
+ this.$unsubscribeButtons.toggleClass('hidden');
+ }
+
+ static setNewTooltip($button) {
+ if (!$button.hasClass('js-subscribe-button')) return;
+
+ const type = $button.hasClass('js-group-level') ? 'group' : 'project';
+ const newTitle = tooltipTitles[type];
+
+ const $el = $('.js-unsubscribe-button', $button.closest('.label-actions-list'));
+ hide($el);
+ $el.attr('title', `${newTitle}`);
+ fixTitle($el);
+ }
+}
diff --git a/app/assets/javascripts/labels/init_labels.js b/app/assets/javascripts/labels/init_labels.js
new file mode 100644
index 00000000000..10bfbf7960c
--- /dev/null
+++ b/app/assets/javascripts/labels/init_labels.js
@@ -0,0 +1,19 @@
+import $ from 'jquery';
+import GroupLabelSubscription from './group_label_subscription';
+import LabelManager from './label_manager';
+import ProjectLabelSubscription from './project_label_subscription';
+
+export default () => {
+ if ($('.prioritized-labels').length) {
+ new LabelManager(); // eslint-disable-line no-new
+ }
+ $('.label-subscription').each((i, el) => {
+ const $el = $(el);
+
+ if ($el.find('.dropdown-group-label').length) {
+ new GroupLabelSubscription($el); // eslint-disable-line no-new
+ } else {
+ new ProjectLabelSubscription($el); // eslint-disable-line no-new
+ }
+ });
+};
diff --git a/app/assets/javascripts/labels/label_manager.js b/app/assets/javascripts/labels/label_manager.js
new file mode 100644
index 00000000000..1927ac6e1ec
--- /dev/null
+++ b/app/assets/javascripts/labels/label_manager.js
@@ -0,0 +1,146 @@
+/* eslint-disable class-methods-use-this, no-underscore-dangle, no-param-reassign, func-names */
+
+import $ from 'jquery';
+import Sortable from 'sortablejs';
+import { dispose } from '~/tooltips';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+
+export default class LabelManager {
+ constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) {
+ this.togglePriorityButton = togglePriorityButton || $('.js-toggle-priority');
+ this.prioritizedLabels = prioritizedLabels || $('.js-prioritized-labels');
+ this.otherLabels = otherLabels || $('.js-other-labels');
+ this.errorMessage = __('Unable to update label prioritization at this time');
+ this.emptyState = document.querySelector('#js-priority-labels-empty-state');
+ this.$badgeItemTemplate = $('#js-badge-item-template');
+
+ if ('sortable' in this.prioritizedLabels.data()) {
+ Sortable.create(this.prioritizedLabels.get(0), {
+ filter: '.empty-message',
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ dataIdAttr: 'data-id',
+ onUpdate: this.onPrioritySortUpdate.bind(this),
+ });
+ }
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick);
+ }
+
+ onTogglePriorityClick(e) {
+ e.preventDefault();
+ const _this = e.data;
+ const $btn = $(e.currentTarget);
+ const $label = $(`#${$btn.data('domId')}`);
+ const action = $btn.parents('.js-prioritized-labels').length ? 'remove' : 'add';
+ const $tooltip = $(`#${$btn.find('.has-tooltip:visible').attr('aria-describedby')}`);
+ dispose($tooltip);
+ _this.toggleLabelPriority($label, action);
+ _this.toggleEmptyState($label, $btn, action);
+ }
+
+ toggleEmptyState() {
+ this.emptyState.classList.toggle(
+ 'hidden',
+ Boolean(this.prioritizedLabels[0].querySelector(':scope > li')),
+ );
+ }
+
+ toggleLabelPriority($label, action, persistState) {
+ if (persistState == null) {
+ persistState = true;
+ }
+ const url = $label.find('.js-toggle-priority').data('url');
+ let $target = this.prioritizedLabels;
+ let $from = this.otherLabels;
+ const rollbackLabelPosition = this.rollbackLabelPosition.bind(this, $label, action);
+
+ if (action === 'remove') {
+ $target = this.otherLabels;
+ $from = this.prioritizedLabels;
+ }
+
+ const $detachedLabel = $label.detach();
+ this.toggleLabelPriorityBadge($detachedLabel, action);
+
+ const $labelEls = $target.find('li.label-list-item');
+
+ /*
+ * If there is a label element in the target, we'd want to
+ * append the new label just right next to it.
+ */
+ if ($labelEls.length) {
+ $labelEls.last().after($detachedLabel);
+ } else {
+ $detachedLabel.appendTo($target);
+ }
+
+ if ($from.find('li').length) {
+ $from.find('.empty-message').removeClass('hidden');
+ }
+ if ($target.find('> li:not(.empty-message)').length) {
+ $target.find('.empty-message').addClass('hidden');
+ }
+ // Return if we are not persisting state
+ if (!persistState) {
+ return;
+ }
+ if (action === 'remove') {
+ axios.delete(url).catch(rollbackLabelPosition);
+
+ // Restore empty message
+ if (!$from.find('li').length) {
+ $from.find('.empty-message').removeClass('hidden');
+ }
+ } else {
+ this.savePrioritySort($label, action).catch(rollbackLabelPosition);
+ }
+ }
+
+ toggleLabelPriorityBadge($label, action) {
+ if (action === 'remove') {
+ $('.js-priority-badge', $label).remove();
+ } else {
+ $('.label-links', $label).append(this.$badgeItemTemplate.clone().html());
+ }
+ }
+
+ onPrioritySortUpdate() {
+ this.savePrioritySort().catch(() =>
+ createFlash({
+ message: this.errorMessage,
+ }),
+ );
+ }
+
+ savePrioritySort() {
+ return axios.post(this.prioritizedLabels.data('url'), {
+ label_ids: this.getSortedLabelsIds(),
+ });
+ }
+
+ rollbackLabelPosition($label, originalAction) {
+ const action = originalAction === 'remove' ? 'add' : 'remove';
+ this.toggleLabelPriority($label, action, false);
+ createFlash({
+ message: this.errorMessage,
+ });
+ }
+
+ getSortedLabelsIds() {
+ const sortedIds = [];
+ this.prioritizedLabels.find('> li').each(function () {
+ const id = $(this).data('id');
+
+ if (id) {
+ sortedIds.push(id);
+ }
+ });
+ return sortedIds;
+ }
+}
diff --git a/app/assets/javascripts/labels/labels.js b/app/assets/javascripts/labels/labels.js
new file mode 100644
index 00000000000..cd8cf0d354c
--- /dev/null
+++ b/app/assets/javascripts/labels/labels.js
@@ -0,0 +1,37 @@
+import $ from 'jquery';
+
+export default class Labels {
+ constructor() {
+ this.setSuggestedColor = this.setSuggestedColor.bind(this);
+ this.updateColorPreview = this.updateColorPreview.bind(this);
+ this.cleanBinding();
+ this.addBinding();
+ this.updateColorPreview();
+ }
+
+ addBinding() {
+ $(document).on('click', '.suggest-colors a', this.setSuggestedColor);
+ return $(document).on('input', 'input#label_color', this.updateColorPreview);
+ }
+ // eslint-disable-next-line class-methods-use-this
+ cleanBinding() {
+ $(document).off('click', '.suggest-colors a');
+ return $(document).off('input', 'input#label_color');
+ }
+ // eslint-disable-next-line class-methods-use-this
+ updateColorPreview() {
+ const previewColor = $('input#label_color').val();
+ return $('div.label-color-preview').css('background-color', previewColor);
+ // Updates the preview color with the hex-color input
+ }
+
+ // Updates the preview color with a click on a suggested color
+ setSuggestedColor(e) {
+ const color = $(e.currentTarget).data('color');
+ $('input#label_color').val(color);
+ this.updateColorPreview();
+ // Notify the form, that color has changed
+ $('.label-form').trigger('keyup');
+ return e.preventDefault();
+ }
+}
diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js
new file mode 100644
index 00000000000..1219a115c89
--- /dev/null
+++ b/app/assets/javascripts/labels/labels_select.js
@@ -0,0 +1,515 @@
+/* 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/bulk_update_sidebar/issuable_bulk_update_actions';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { sprintf, __ } from '~/locale';
+import CreateLabelDropdown from './create_label';
+
+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 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 = `<span class="no-value">${__('None')}</span>`;
+ }
+ $value.removeAttr('style').html(template);
+ $sidebarCollapsedValue.text(labelCount);
+
+ $('.has-tooltip', $value).tooltip({
+ container: 'body',
+ });
+ })
+ .catch(() =>
+ createFlash({
+ 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(() =>
+ createFlash({
+ 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 = `<span class='dropdown-label-box' style='background: ${label.color}'></span>`;
+ } 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')
+ .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 =
+ '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>" class="gl-link gl-label-link has-tooltip" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>">';
+ const labelTemplate = template(
+ [
+ '<span class="gl-label">',
+ linkOpenTag,
+ '<span class="gl-label-text <%= labelTextClass({ label, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>;">',
+ '<%- label.title %>',
+ '</span>',
+ '</a>',
+ '</span>',
+ ].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(
+ [
+ '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>; --label-inset-border: inset 0 0 0 2px <%= escapeStr(label.color) %>;">',
+ linkOpenTag,
+ '<span class="gl-label-text <%= labelTextClass({ label, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>;">',
+ '<%- label.title.slice(0, label.title.lastIndexOf("::")) %>',
+ '</span>',
+ '<span class="gl-label-text <%= rightLabelTextClass({ label, escapeStr }) %>">',
+ '<%- label.title.slice(label.title.lastIndexOf("::") + 2) %>',
+ '</span>',
+ '</a>',
+ '</span>',
+ ].join(''),
+ );
+
+ const tooltipTitleTemplate = template(
+ [
+ '<% if (isScopedLabel(label) && enableScopedLabels) { %>',
+ "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>",
+ '<br />',
+ '<%= 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);
+ }
+}
diff --git a/app/assets/javascripts/labels/project_label_subscription.js b/app/assets/javascripts/labels/project_label_subscription.js
new file mode 100644
index 00000000000..b2612e9ede0
--- /dev/null
+++ b/app/assets/javascripts/labels/project_label_subscription.js
@@ -0,0 +1,77 @@
+import $ from 'jquery';
+import { fixTitle } from '~/tooltips';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+
+const tooltipTitles = {
+ group: {
+ subscribed: __('Unsubscribe at group level'),
+ unsubscribed: __('Subscribe at group level'),
+ },
+ project: {
+ subscribed: __('Unsubscribe at project level'),
+ unsubscribed: __('Subscribe at project level'),
+ },
+};
+
+export default class ProjectLabelSubscription {
+ constructor(container) {
+ this.$container = $(container);
+ this.$buttons = this.$container.find('.js-subscribe-button');
+
+ this.$buttons.on('click', this.toggleSubscription.bind(this));
+ }
+
+ toggleSubscription(event) {
+ event.preventDefault();
+
+ const $btn = $(event.currentTarget);
+ const url = $btn.attr('data-url');
+ const oldStatus = $btn.attr('data-status');
+
+ $btn.addClass('disabled');
+
+ axios
+ .post(url)
+ .then(() => {
+ let newStatus;
+ let newAction;
+
+ if (oldStatus === 'unsubscribed') {
+ [newStatus, newAction] = ['subscribed', __('Unsubscribe')];
+ } else {
+ [newStatus, newAction] = ['unsubscribed', __('Subscribe')];
+ }
+
+ $btn.removeClass('disabled');
+
+ this.$buttons.attr('data-status', newStatus);
+ this.$buttons.find('> span').text(newAction);
+
+ this.$buttons.map((i, button) => {
+ const $button = $(button);
+ const originalTitle = $button.attr('data-original-title');
+
+ if (originalTitle) {
+ ProjectLabelSubscription.setNewTitle($button, originalTitle, newStatus, newAction);
+ }
+
+ return button;
+ });
+ })
+ .catch(() =>
+ createFlash({
+ message: __('There was an error subscribing to this label.'),
+ }),
+ );
+ }
+
+ static setNewTitle($button, originalTitle, newStatus) {
+ const type = /group/.test(originalTitle) ? 'group' : 'project';
+ const newTitle = tooltipTitles[type][newStatus];
+
+ $button.attr('title', newTitle);
+ fixTitle($button);
+ }
+}