summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/labels
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/labels')
-rw-r--r--app/assets/javascripts/labels/components/delete_label_modal.vue81
-rw-r--r--app/assets/javascripts/labels/components/promote_label_modal.vue113
-rw-r--r--app/assets/javascripts/labels/create_label_dropdown.js131
-rw-r--r--app/assets/javascripts/labels/event_hub.js3
-rw-r--r--app/assets/javascripts/labels/group_label_subscription.js76
-rw-r--r--app/assets/javascripts/labels/index.js137
-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
10 files changed, 1316 insertions, 0 deletions
diff --git a/app/assets/javascripts/labels/components/delete_label_modal.vue b/app/assets/javascripts/labels/components/delete_label_modal.vue
new file mode 100644
index 00000000000..1ff0938d086
--- /dev/null
+++ b/app/assets/javascripts/labels/components/delete_label_modal.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlModal, GlSprintf, GlButton } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlButton,
+ },
+ props: {
+ selector: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ labelName: '',
+ subjectName: '',
+ destroyPath: '',
+ modalId: uniqueId('modal-delete-label-'),
+ };
+ },
+ mounted() {
+ document.querySelectorAll(this.selector).forEach((button) => {
+ button.addEventListener('click', (e) => {
+ e.preventDefault();
+
+ const { labelName, subjectName, destroyPath } = button.dataset;
+ this.labelName = labelName;
+ this.subjectName = subjectName;
+ this.destroyPath = destroyPath;
+ this.openModal();
+ });
+ });
+ },
+ methods: {
+ openModal() {
+ this.$refs.modal.show();
+ },
+ closeModal() {
+ this.$refs.modal.hide();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal ref="modal" :modal-id="modalId">
+ <template #modal-title>
+ <gl-sprintf :message="__('Delete label: %{labelName}')">
+ <template #labelName>
+ {{ labelName }}
+ </template>
+ </gl-sprintf>
+ </template>
+ <gl-sprintf
+ :message="
+ __(
+ `%{strongStart}${labelName}%{strongEnd} will be permanently deleted from ${subjectName}. This cannot be undone.`,
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ <template #modal-footer>
+ <gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button>
+ <gl-button
+ category="primary"
+ variant="danger"
+ :href="destroyPath"
+ data-method="delete"
+ data-testid="delete-button"
+ >{{ __('Delete label') }}</gl-button
+ >
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/labels/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue
new file mode 100644
index 00000000000..e708cd32fff
--- /dev/null
+++ b/app/assets/javascripts/labels/components/promote_label_modal.vue
@@ -0,0 +1,113 @@
+<script>
+import { GlSprintf, GlModal } from '@gitlab/ui';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { s__, __, sprintf } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ primaryProps: {
+ text: s__('Labels|Promote Label'),
+ attributes: [{ variant: 'warning' }, { category: 'primary' }],
+ },
+ cancelProps: {
+ text: __('Cancel'),
+ },
+ components: {
+ GlModal,
+ GlSprintf,
+ },
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ labelTitle: {
+ type: String,
+ required: true,
+ },
+ labelColor: {
+ type: String,
+ required: true,
+ },
+ labelTextColor: {
+ type: String,
+ required: true,
+ },
+ groupName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ return sprintf(
+ s__(`Labels|Promoting %{labelTitle} will make it available for all projects inside %{groupName}.
+ Existing project labels with the same title will be merged. If a group label with the same title exists,
+ it will also be merged. This action cannot be reversed.`),
+ {
+ labelTitle: this.labelTitle,
+ groupName: this.groupName,
+ },
+ );
+ },
+ },
+ methods: {
+ onSubmit() {
+ eventHub.$emit('promoteLabelModal.requestStarted', this.url);
+ return axios
+ .post(this.url, { params: { format: 'json' } })
+ .then((response) => {
+ eventHub.$emit('promoteLabelModal.requestFinished', {
+ labelUrl: this.url,
+ successful: true,
+ });
+ visitUrl(response.data.url);
+ })
+ .catch((error) => {
+ eventHub.$emit('promoteLabelModal.requestFinished', {
+ labelUrl: this.url,
+ successful: false,
+ });
+ createFlash({
+ message: error,
+ });
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ modal-id="promote-label-modal"
+ :action-primary="$options.primaryProps"
+ :action-cancel="$options.cancelProps"
+ @primary="onSubmit"
+ >
+ <template #modal-title>
+ <div class="modal-title-with-label">
+ <gl-sprintf
+ :message="
+ s__(
+ 'Labels|%{spanStart}Promote label%{spanEnd} %{labelTitle} %{spanStart}to Group Label?%{spanEnd}',
+ )
+ "
+ >
+ <template #labelTitle>
+ <span
+ class="label color-label"
+ :style="`background-color: ${labelColor}; color: ${labelTextColor};`"
+ >
+ {{ labelTitle }}
+ </span>
+ </template>
+ <template #span="{ content }"
+ ><span>{{ content }}</span></template
+ >
+ </gl-sprintf>
+ </div>
+ </template>
+ {{ text }}
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/labels/create_label_dropdown.js b/app/assets/javascripts/labels/create_label_dropdown.js
new file mode 100644
index 00000000000..8c166158a44
--- /dev/null
+++ b/app/assets/javascripts/labels/create_label_dropdown.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/event_hub.js b/app/assets/javascripts/labels/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/labels/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
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/index.js b/app/assets/javascripts/labels/index.js
new file mode 100644
index 00000000000..22a9c0a89c0
--- /dev/null
+++ b/app/assets/javascripts/labels/index.js
@@ -0,0 +1,137 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import { BV_SHOW_MODAL } from '~/lib/utils/constants';
+import Translate from '~/vue_shared/translate';
+import DeleteLabelModal from './components/delete_label_modal.vue';
+import PromoteLabelModal from './components/promote_label_modal.vue';
+import eventHub from './event_hub';
+import GroupLabelSubscription from './group_label_subscription';
+import LabelManager from './label_manager';
+import ProjectLabelSubscription from './project_label_subscription';
+
+export function initDeleteLabelModal(optionalProps = {}) {
+ new Vue({
+ render(h) {
+ return h(DeleteLabelModal, {
+ props: {
+ selector: '.js-delete-label-modal-button',
+ ...optionalProps,
+ },
+ });
+ },
+ }).$mount();
+}
+
+export function initLabels() {
+ 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
+ }
+ });
+}
+
+export function initLabelIndex() {
+ Vue.use(Translate);
+
+ initLabels();
+ initDeleteLabelModal();
+
+ const onRequestFinished = ({ labelUrl, successful }) => {
+ const button = document.querySelector(
+ `.js-promote-project-label-button[data-url="${labelUrl}"]`,
+ );
+
+ if (!successful) {
+ button.removeAttribute('disabled');
+ }
+ };
+
+ const onRequestStarted = (labelUrl) => {
+ const button = document.querySelector(
+ `.js-promote-project-label-button[data-url="${labelUrl}"]`,
+ );
+ button.setAttribute('disabled', '');
+ eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished);
+ };
+
+ const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button');
+
+ return new Vue({
+ el: '#js-promote-label-modal',
+ data() {
+ return {
+ modalProps: {
+ labelTitle: '',
+ labelColor: '',
+ labelTextColor: '',
+ url: '',
+ groupName: '',
+ },
+ };
+ },
+ mounted() {
+ eventHub.$on('promoteLabelModal.props', this.setModalProps);
+ eventHub.$emit('promoteLabelModal.mounted');
+
+ promoteLabelButtons.forEach((button) => {
+ button.removeAttribute('disabled');
+ button.addEventListener('click', () => {
+ this.$root.$emit(BV_SHOW_MODAL, 'promote-label-modal');
+ eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted);
+
+ this.setModalProps({
+ labelTitle: button.dataset.labelTitle,
+ labelColor: button.dataset.labelColor,
+ labelTextColor: button.dataset.labelTextColor,
+ url: button.dataset.url,
+ groupName: button.dataset.groupName,
+ });
+ });
+ });
+ },
+ beforeDestroy() {
+ eventHub.$off('promoteLabelModal.props', this.setModalProps);
+ },
+ methods: {
+ setModalProps(modalProps) {
+ this.modalProps = modalProps;
+ },
+ },
+ render(createElement) {
+ return createElement(PromoteLabelModal, {
+ props: this.modalProps,
+ });
+ },
+ });
+}
+
+export function initAdminLabels() {
+ const labelsContainer = document.querySelector('.js-admin-labels-container');
+ const pagination = labelsContainer?.querySelector('.gl-pagination');
+ const emptyState = document.querySelector('.js-admin-labels-empty-state');
+
+ function removeLabelSuccessCallback() {
+ this.closest('li').classList.add('gl-display-none!');
+
+ const labelsCount = document.querySelectorAll(
+ 'ul.manage-labels-list li:not(.gl-display-none\\!)',
+ ).length;
+
+ // display the empty state if there are no more labels
+ if (labelsCount < 1 && !pagination && emptyState) {
+ emptyState.classList.remove('gl-display-none');
+ labelsContainer.classList.add('gl-display-none');
+ }
+ }
+
+ document.querySelectorAll('.js-remove-label').forEach((row) => {
+ row.addEventListener('ajax:success', removeLabelSuccessCallback);
+ });
+}
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..9d8ee165df2
--- /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_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 = `<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);
+ }
+}