diff options
Diffstat (limited to 'app/assets/javascripts/labels')
-rw-r--r-- | app/assets/javascripts/labels/components/delete_label_modal.vue | 81 | ||||
-rw-r--r-- | app/assets/javascripts/labels/components/promote_label_modal.vue | 113 | ||||
-rw-r--r-- | app/assets/javascripts/labels/create_label_dropdown.js | 131 | ||||
-rw-r--r-- | app/assets/javascripts/labels/event_hub.js | 3 | ||||
-rw-r--r-- | app/assets/javascripts/labels/group_label_subscription.js | 76 | ||||
-rw-r--r-- | app/assets/javascripts/labels/index.js | 137 | ||||
-rw-r--r-- | app/assets/javascripts/labels/label_manager.js | 146 | ||||
-rw-r--r-- | app/assets/javascripts/labels/labels.js | 37 | ||||
-rw-r--r-- | app/assets/javascripts/labels/labels_select.js | 515 | ||||
-rw-r--r-- | app/assets/javascripts/labels/project_label_subscription.js | 77 |
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); + } +} |