diff options
Diffstat (limited to 'app/assets/javascripts/issuable_bulk_update_sidebar')
7 files changed, 438 insertions, 0 deletions
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue b/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue new file mode 100644 index 00000000000..9509399e91d --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue @@ -0,0 +1,58 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { ISSUE_STATUS_SELECT_OPTIONS } from '../constants'; + +export default { + name: 'StatusSelect', + components: { + GlDropdown, + GlDropdownItem, + }, + data() { + return { + status: null, + }; + }, + computed: { + dropdownText() { + return this.status?.text ?? this.$options.i18n.defaultDropdownText; + }, + selectedValue() { + return this.status?.value; + }, + }, + methods: { + onDropdownItemClick(statusOption) { + // clear status if the currently checked status is clicked again + if (this.status?.value === statusOption.value) { + this.status = null; + } else { + this.status = statusOption; + } + }, + }, + i18n: { + dropdownTitle: __('Change status'), + defaultDropdownText: __('Select status'), + }, + ISSUE_STATUS_SELECT_OPTIONS, +}; +</script> +<template> + <div> + <input type="hidden" name="update[state_event]" :value="selectedValue" /> + <gl-dropdown :text="dropdownText" :title="$options.i18n.dropdownTitle" class="gl-w-full"> + <gl-dropdown-item + v-for="statusOption in $options.ISSUE_STATUS_SELECT_OPTIONS" + :key="statusOption.value" + :is-checked="selectedValue === statusOption.value" + is-check-item + :title="statusOption.text" + @click="onDropdownItemClick(statusOption)" + > + {{ statusOption.text }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js b/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js new file mode 100644 index 00000000000..ad15b25f9cf --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js @@ -0,0 +1,17 @@ +import { __ } from '~/locale'; + +export const ISSUE_STATUS_MODIFIERS = { + REOPEN: 'reopen', + CLOSE: 'close', +}; + +export const ISSUE_STATUS_SELECT_OPTIONS = [ + { + value: ISSUE_STATUS_MODIFIERS.REOPEN, + text: __('Open'), + }, + { + value: ISSUE_STATUS_MODIFIERS.CLOSE, + text: __('Closed'), + }, +]; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js b/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js new file mode 100644 index 00000000000..43179a86d70 --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import StatusSelect from './components/status_select.vue'; + +export default function initIssueStatusSelect() { + const el = document.querySelector('.js-issue-status'); + + if (!el) { + return null; + } + + return new Vue({ + el, + render(h) { + return h(StatusSelect); + }, + }); +} diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js new file mode 100644 index 00000000000..463e0e5837e --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js @@ -0,0 +1,126 @@ +import $ from 'jquery'; +import { difference, intersection, union } from 'lodash'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; + +export default { + init({ form, issues, prefixId } = {}) { + this.prefixId = prefixId || 'issue_'; + this.form = form || this.getElement('.bulk-update'); + this.$labelDropdown = this.form.find('.js-label-select'); + this.issues = issues || this.getElement('.issues-list .issue'); + this.willUpdateLabels = false; + this.bindEvents(); + }, + + bindEvents() { + // eslint-disable-next-line @gitlab/no-global-event-off + return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); + }, + + onFormSubmit(e) { + e.preventDefault(); + return this.submit(); + }, + + submit() { + axios[this.form.attr('method')](this.form.attr('action'), this.getFormDataAsObject()) + .then(() => window.location.reload()) + .catch(() => this.onFormSubmitFailure()); + }, + + onFormSubmitFailure() { + this.form.find('[type="submit"]').enable(); + return createFlash({ + message: __('Issue update failed'), + }); + }, + + /** + * Simple form serialization, it will return just what we need + * Returns key/value pairs from form data + */ + + getFormDataAsObject() { + const formData = { + update: { + state_event: this.form.find('input[name="update[state_event]"]').val(), + assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], + milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), + issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), + subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), + health_status: this.form.find('input[name="update[health_status]"]').val(), + epic_id: this.form.find('input[name="update[epic_id]"]').val(), + sprint_id: this.form.find('input[name="update[iteration_id]"]').val(), + add_label_ids: [], + remove_label_ids: [], + }, + }; + if (this.willUpdateLabels) { + formData.update.add_label_ids = this.$labelDropdown.data('user-checked'); + formData.update.remove_label_ids = this.$labelDropdown.data('user-unchecked'); + } + return formData; + }, + + setOriginalDropdownData() { + const $labelSelect = $('.bulk-update .js-label-select'); + const userCheckedIds = $labelSelect.data('user-checked') || []; + const userUncheckedIds = $labelSelect.data('user-unchecked') || []; + + // Common labels plus user checked labels minus user unchecked labels + const checkedIdsToShow = difference( + union(this.getOriginalCommonIds(), userCheckedIds), + userUncheckedIds, + ); + + // Indeterminate labels minus user checked labels minus user unchecked labels + const indeterminateIdsToShow = difference( + this.getOriginalIndeterminateIds(), + userCheckedIds, + userUncheckedIds, + ); + + $labelSelect.data('marked', checkedIdsToShow); + $labelSelect.data('indeterminate', indeterminateIdsToShow); + }, + + // From issuable's initial bulk selection + getOriginalCommonIds() { + const labelIds = []; + this.getElement('.issuable-list input[type="checkbox"]:checked').each((i, el) => { + labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); + }); + return intersection.apply(this, labelIds); + }, + + // From issuable's initial bulk selection + getOriginalIndeterminateIds() { + const uniqueIds = []; + const labelIds = []; + let issuableLabels = []; + + // Collect unique label IDs for all checked issues + this.getElement('.issuable-list input[type="checkbox"]:checked').each((i, el) => { + issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); + issuableLabels.forEach((labelId) => { + // Store unique IDs + if (uniqueIds.indexOf(labelId) === -1) { + uniqueIds.push(labelId); + } + }); + // Store array of IDs per issuable + labelIds.push(issuableLabels); + }); + // Add uniqueIds to add it as argument for _.intersection + labelIds.unshift(uniqueIds); + // Return IDs that are present but not in all selected issueables + return uniqueIds.filter((x) => !intersection.apply(this, labelIds).includes(x)); + }, + + getElement(selector) { + this.scopeEl = this.scopeEl || $('.content'); + return this.scopeEl.find(selector); + }, +}; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js new file mode 100644 index 00000000000..a9d4548f8cf --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js @@ -0,0 +1,173 @@ +/* eslint-disable class-methods-use-this, no-new */ + +import $ from 'jquery'; +import { property } from 'lodash'; + +import issueableEventHub from '~/issues_list/eventhub'; +import LabelsSelect from '~/labels_select'; +import MilestoneSelect from '~/milestone_select'; +import initIssueStatusSelect from './init_issue_status_select'; +import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; +import subscriptionSelect from './subscription_select'; + +const HIDDEN_CLASS = 'hidden'; +const DISABLED_CONTENT_CLASS = 'disabled-content'; +const SIDEBAR_EXPANDED_CLASS = 'right-sidebar-expanded issuable-bulk-update-sidebar'; +const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-sidebar'; + +export default class IssuableBulkUpdateSidebar { + constructor() { + this.vueIssuablesListFeature = property(['gon', 'features', 'vueIssuablesList'])(window); + + this.initDomElements(); + this.bindEvents(); + this.initDropdowns(); + this.setupBulkUpdateActions(); + } + + initDomElements() { + this.$page = $('.layout-page'); + this.$sidebar = $('.right-sidebar'); + this.$sidebarInnerContainer = this.$sidebar.find('.issuable-sidebar'); + this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide'); + this.$bulkEditSubmitBtn = $('.js-update-selected-issues'); + this.$bulkUpdateEnableBtn = $('.js-bulk-update-toggle'); + this.$otherFilters = $('.issues-other-filters'); + this.$checkAllContainer = $('.check-all-holder'); + this.$issueChecks = $('.issue-check'); + this.$issuesList = $('.issuable-list input[type="checkbox"]'); + this.$issuableIdsInput = $('#update_issuable_ids'); + } + + bindEvents() { + this.$bulkUpdateEnableBtn.on('click', (e) => this.toggleBulkEdit(e, true)); + this.$bulkEditCancelBtn.on('click', (e) => this.toggleBulkEdit(e, false)); + this.$checkAllContainer.on('click', (e) => this.selectAll(e)); + this.$issuesList.on('change', () => this.updateFormState()); + this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit()); + this.$checkAllContainer.on('click', () => this.updateFormState()); + + // The event hub connects this bulk update logic with `issues_list_app.vue`. + // We can remove it once we've refactored the issues list page bulk edit sidebar to Vue. + // https://gitlab.com/gitlab-org/gitlab/-/issues/325874 + issueableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true)); + issueableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState()); + } + + initDropdowns() { + new LabelsSelect(); + new MilestoneSelect(); + initIssueStatusSelect(); + subscriptionSelect(); + + if (IS_EE) { + import('ee/vue_shared/components/sidebar/health_status_select/health_status_bundle') + .then(({ default: HealthStatusSelect }) => { + HealthStatusSelect(); + }) + .catch(() => {}); + } + + if (IS_EE) { + import('ee/vue_shared/components/sidebar/epics_select/epics_select_bundle') + .then(({ default: EpicSelect }) => { + EpicSelect(); + }) + .catch(() => {}); + } + + if (IS_EE) { + import('ee/vue_shared/components/sidebar/iterations_dropdown_bundle') + .then(({ default: iterationsDropdown }) => { + iterationsDropdown(); + }) + .catch((e) => { + throw e; + }); + } + } + + setupBulkUpdateActions() { + IssuableBulkUpdateActions.setOriginalDropdownData(); + } + + updateFormState() { + const noCheckedIssues = !$('.issuable-list input[type="checkbox"]:checked').length; + + this.toggleSubmitButtonDisabled(noCheckedIssues); + this.updateSelectedIssuableIds(); + + IssuableBulkUpdateActions.setOriginalDropdownData(); + } + + prepForSubmit() { + // if submit button is disabled, submission is blocked. This ensures we disable after + // form submission is carried out + setTimeout(() => this.$bulkEditSubmitBtn.disable()); + this.updateSelectedIssuableIds(); + } + + toggleBulkEdit(e, enable) { + e?.preventDefault(); + + issueableEventHub.$emit('issuables:toggleBulkEdit', enable); + + this.toggleSidebarDisplay(enable); + this.toggleBulkEditButtonDisabled(enable); + this.toggleOtherFiltersDisabled(enable); + this.toggleCheckboxDisplay(enable); + } + + updateSelectedIssuableIds() { + this.$issuableIdsInput.val(IssuableBulkUpdateSidebar.getCheckedIssueIds()); + } + + selectAll() { + const checkAllButtonState = this.$checkAllContainer.find('input').prop('checked'); + + this.$issuesList.prop('checked', checkAllButtonState); + } + + toggleSidebarDisplay(show) { + this.$page.toggleClass(SIDEBAR_EXPANDED_CLASS, show); + this.$page.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); + this.$sidebarInnerContainer.toggleClass(HIDDEN_CLASS, !show); + this.$sidebar.toggleClass(SIDEBAR_EXPANDED_CLASS, show); + this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); + } + + toggleBulkEditButtonDisabled(disable) { + if (disable) { + this.$bulkUpdateEnableBtn.disable(); + } else { + this.$bulkUpdateEnableBtn.enable(); + } + } + + toggleCheckboxDisplay(show) { + this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show || this.vueIssuablesListFeature); + this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); + } + + toggleOtherFiltersDisabled(disable) { + this.$otherFilters.toggleClass(DISABLED_CONTENT_CLASS, disable); + } + + toggleSubmitButtonDisabled(disable) { + if (disable) { + this.$bulkEditSubmitBtn.disable(); + } else { + this.$bulkEditSubmitBtn.enable(); + } + } + + static getCheckedIssueIds() { + const $checkedIssues = $('.issuable-list input[type="checkbox"]:checked'); + + if ($checkedIssues.length > 0) { + return $.map($checkedIssues, (value) => $(value).data('id')); + } + + return []; + } +} diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js new file mode 100644 index 00000000000..179c2b83c6c --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js @@ -0,0 +1,19 @@ +import issuableBulkUpdateActions from './issuable_bulk_update_actions'; +import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; + +export default { + bulkUpdateSidebar: null, + + init(prefixId) { + const bulkUpdateEl = document.querySelector('.issues-bulk-update'); + const alreadyInitialized = Boolean(this.bulkUpdateSidebar); + + if (bulkUpdateEl && !alreadyInitialized) { + issuableBulkUpdateActions.init({ prefixId }); + + this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); + } + + return this.bulkUpdateSidebar; + }, +}; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js b/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js new file mode 100644 index 00000000000..b12ac776b4f --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js @@ -0,0 +1,28 @@ +import $ from 'jquery'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { __ } from '~/locale'; + +export default function subscriptionSelect() { + $('.js-subscription-event').each((i, element) => { + const fieldName = $(element).data('fieldName'); + + return initDeprecatedJQueryDropdown($(element), { + selectable: true, + fieldName, + toggleLabel(selected, el, instance) { + let label = __('Subscription'); + const $item = instance.dropdown.find('.is-active'); + if ($item.length) { + label = $item.text(); + } + return label; + }, + clicked(options) { + return options.e.preventDefault(); + }, + id(obj, el) { + return $(el).data('id'); + }, + }); + }); +} |