From aee0a117a889461ce8ced6fcf73207fe017f1d99 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Mon, 20 Dec 2021 13:37:47 +0000 Subject: Add latest changes from gitlab-org/gitlab@14-6-stable-ee --- .../components/delete_milestone_modal.vue | 137 +++++++++++ .../components/promote_milestone_modal.vue | 104 ++++++++ app/assets/javascripts/milestones/event_hub.js | 3 + app/assets/javascripts/milestones/index.js | 123 ++++++++++ app/assets/javascripts/milestones/milestone.js | 49 ++++ .../javascripts/milestones/milestone_select.js | 273 +++++++++++++++++++++ .../javascripts/milestones/milestone_utils.js | 32 --- app/assets/javascripts/milestones/utils.js | 32 +++ 8 files changed, 721 insertions(+), 32 deletions(-) create mode 100644 app/assets/javascripts/milestones/components/delete_milestone_modal.vue create mode 100644 app/assets/javascripts/milestones/components/promote_milestone_modal.vue create mode 100644 app/assets/javascripts/milestones/event_hub.js create mode 100644 app/assets/javascripts/milestones/index.js create mode 100644 app/assets/javascripts/milestones/milestone.js create mode 100644 app/assets/javascripts/milestones/milestone_select.js delete mode 100644 app/assets/javascripts/milestones/milestone_utils.js create mode 100644 app/assets/javascripts/milestones/utils.js (limited to 'app/assets/javascripts/milestones') diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue new file mode 100644 index 00000000000..34f9fe778ea --- /dev/null +++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue @@ -0,0 +1,137 @@ + + + diff --git a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue new file mode 100644 index 00000000000..b41611001ab --- /dev/null +++ b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue @@ -0,0 +1,104 @@ + + diff --git a/app/assets/javascripts/milestones/event_hub.js b/app/assets/javascripts/milestones/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/milestones/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js new file mode 100644 index 00000000000..2ca5f104b4f --- /dev/null +++ b/app/assets/javascripts/milestones/index.js @@ -0,0 +1,123 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import initDatePicker from '~/behaviors/date_picker'; +import GLForm from '~/gl_form'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import Milestone from '~/milestones/milestone'; +import Sidebar from '~/right_sidebar'; +import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; +import Translate from '~/vue_shared/translate'; +import ZenMode from '~/zen_mode'; +import DeleteMilestoneModal from './components/delete_milestone_modal.vue'; +import PromoteMilestoneModal from './components/promote_milestone_modal.vue'; +import eventHub from './event_hub'; + +export function initForm(initGFM = true) { + new ZenMode(); // eslint-disable-line no-new + initDatePicker(); + + // eslint-disable-next-line no-new + new GLForm($('.milestone-form'), { + emojis: true, + members: initGFM, + issues: initGFM, + mergeRequests: initGFM, + epics: initGFM, + milestones: initGFM, + labels: initGFM, + snippets: initGFM, + vulnerabilities: initGFM, + }); +} + +export function initShow() { + new Milestone(); // eslint-disable-line no-new + new Sidebar(); // eslint-disable-line no-new + new MountMilestoneSidebar(); // eslint-disable-line no-new +} + +export function initPromoteMilestoneModal() { + Vue.use(Translate); + + const promoteMilestoneModal = document.getElementById('promote-milestone-modal'); + if (!promoteMilestoneModal) { + return null; + } + + return new Vue({ + el: promoteMilestoneModal, + render(createElement) { + return createElement(PromoteMilestoneModal); + }, + }); +} + +export function initDeleteMilestoneModal() { + Vue.use(Translate); + + const onRequestFinished = ({ milestoneUrl, successful }) => { + const button = document.querySelector( + `.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`, + ); + + if (!successful) { + button.removeAttribute('disabled'); + } + + button.querySelector('.js-loading-icon').classList.add('hidden'); + }; + + const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button'); + + const onRequestStarted = (milestoneUrl) => { + const button = document.querySelector( + `.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`, + ); + button.setAttribute('disabled', ''); + button.querySelector('.js-loading-icon').classList.remove('hidden'); + eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished); + }; + + return new Vue({ + el: '#js-delete-milestone-modal', + data() { + return { + modalProps: { + milestoneId: -1, + milestoneTitle: '', + milestoneUrl: '', + issueCount: -1, + mergeRequestCount: -1, + }, + }; + }, + mounted() { + eventHub.$on('deleteMilestoneModal.props', this.setModalProps); + deleteMilestoneButtons.forEach((button) => { + button.removeAttribute('disabled'); + button.addEventListener('click', () => { + this.$root.$emit(BV_SHOW_MODAL, 'delete-milestone-modal'); + eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted); + + this.setModalProps({ + milestoneId: parseInt(button.dataset.milestoneId, 10), + milestoneTitle: button.dataset.milestoneTitle, + milestoneUrl: button.dataset.milestoneUrl, + issueCount: parseInt(button.dataset.milestoneIssueCount, 10), + mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10), + }); + }); + }); + }, + methods: { + setModalProps(modalProps) { + this.modalProps = modalProps; + }, + }, + render(createElement) { + return createElement(DeleteMilestoneModal, { + props: this.modalProps, + }); + }, + }); +} diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js new file mode 100644 index 00000000000..05102f73f92 --- /dev/null +++ b/app/assets/javascripts/milestones/milestone.js @@ -0,0 +1,49 @@ +import createFlash from '~/flash'; +import { sanitize } from '~/lib/dompurify'; +import axios from '~/lib/utils/axios_utils'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs'; + +export default class Milestone { + constructor() { + this.tabsEl = document.querySelector('.js-milestone-tabs'); + this.glTabs = new GlTabsBehavior(this.tabsEl); + this.loadedTabs = new WeakSet(); + + this.bindTabsSwitching(); + this.loadInitialTab(); + } + + bindTabsSwitching() { + this.tabsEl.addEventListener(TAB_SHOWN_EVENT, (event) => { + const tab = event.target; + const { activeTabPanel } = event.detail; + historyPushState(tab.getAttribute('href')); + this.loadTab(tab, activeTabPanel); + }); + } + + loadInitialTab() { + const tab = this.tabsEl.querySelector(`a[href="${window.location.hash}"]`); + this.glTabs.activateTab(tab || this.glTabs.activeTab); + } + loadTab(tab, tabPanel) { + const { endpoint } = tab.dataset; + + if (endpoint && !this.loadedTabs.has(tab)) { + axios + .get(endpoint) + .then(({ data }) => { + // eslint-disable-next-line no-param-reassign + tabPanel.innerHTML = sanitize(data.html); + this.loadedTabs.add(tab); + }) + .catch(() => + createFlash({ + message: __('Error loading milestone tab'), + }), + ); + } + } +} diff --git a/app/assets/javascripts/milestones/milestone_select.js b/app/assets/javascripts/milestones/milestone_select.js new file mode 100644 index 00000000000..c95ec3dd10b --- /dev/null +++ b/app/assets/javascripts/milestones/milestone_select.js @@ -0,0 +1,273 @@ +/* eslint-disable one-var, no-self-compare, consistent-return, no-param-reassign, no-shadow */ +/* global Issuable */ + +import $ from 'jquery'; +import { template, escape } from 'lodash'; +import Api from '~/api'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { __, sprintf } from '~/locale'; +import { sortMilestonesByDueDate } from '~/milestones/utils'; +import axios from '~/lib/utils/axios_utils'; +import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility'; + +export default class MilestoneSelect { + constructor(currentProject, els, options = {}) { + if (currentProject !== null) { + this.currentProject = + typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject; + } + + MilestoneSelect.init(els, options); + } + + static init(els, options) { + let $els = $(els); + + if (!els) { + $els = $('.js-milestone-select'); + } + + $els.each((i, dropdown) => { + let milestoneLinkNoneTemplate, + milestoneLinkTemplate, + milestoneExpiredLinkTemplate, + selectedMilestone, + selectedMilestoneDefault; + const $dropdown = $(dropdown); + const issueUpdateURL = $dropdown.data('issueUpdate'); + const showNo = $dropdown.data('showNo'); + const showAny = $dropdown.data('showAny'); + const showMenuAbove = $dropdown.data('showMenuAbove'); + const showUpcoming = $dropdown.data('showUpcoming'); + const showStarted = $dropdown.data('showStarted'); + const useId = $dropdown.data('useId'); + const defaultLabel = $dropdown.data('defaultLabel'); + const defaultNo = $dropdown.data('defaultNo'); + const abilityName = $dropdown.data('abilityName'); + const $selectBox = $dropdown.closest('.selectbox'); + const $block = $selectBox.closest('.block'); + const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); + const $value = $block.find('.value'); + const $loading = $block.find('.block-loading').addClass('gl-display-none'); + selectedMilestoneDefault = showAny ? '' : null; + selectedMilestoneDefault = + showNo && defaultNo ? __('No milestone') : selectedMilestoneDefault; + selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault; + + if (issueUpdateURL) { + milestoneLinkTemplate = template( + '<%- title %>', + ); + milestoneExpiredLinkTemplate = template( + '<%- title %> (Past due)', + ); + milestoneLinkNoneTemplate = `${__('None')}`; + } + return initDeprecatedJQueryDropdown($dropdown, { + showMenuAbove, + data: (term, callback) => { + let contextId = parseInt($dropdown.get(0).dataset.projectId, 10); + let getMilestones = Api.projectMilestones.bind(Api); + const reqParams = { state: 'active', include_parent_milestones: true }; + + if (term) { + reqParams.search = term.trim(); + } + + if (!contextId) { + contextId = $dropdown.get(0).dataset.groupId; + delete reqParams.include_parent_milestones; + getMilestones = Api.groupMilestones.bind(Api); + } + + // We don't use $.data() as it caches initial value and never updates! + return getMilestones(contextId, reqParams) + .then(({ data }) => + data + .map((m) => ({ + ...m, + // Public API includes `title` instead of `name`. + name: m.title, + })) + .sort(sortMilestonesByDueDate), + ) + .then((data) => { + const extraOptions = []; + if (showAny) { + extraOptions.push({ + id: null, + name: null, + title: __('Any milestone'), + }); + } + if (showNo && term.trim() === '') { + extraOptions.push({ + id: -1, + name: __('No milestone'), + title: __('No milestone'), + }); + } + if (showUpcoming) { + extraOptions.push({ + id: -2, + name: '#upcoming', + title: __('Upcoming'), + }); + } + if (showStarted) { + extraOptions.push({ + id: -3, + name: '#started', + title: __('Started'), + }); + } + if (extraOptions.length) { + extraOptions.push({ type: 'divider' }); + } + + callback(extraOptions.concat(data)); + if (showMenuAbove) { + $dropdown.data('deprecatedJQueryDropdown').positionMenuAbove(); + } + $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active'); + }); + }, + renderRow: (milestone) => { + const milestoneName = milestone.title || milestone.name; + let milestoneDisplayName = escape(milestoneName); + + if (milestone.expired) { + milestoneDisplayName = sprintf(__('%{milestone} (expired)'), { + milestone: milestoneDisplayName, + }); + } + + return ` +
  • + + ${milestoneDisplayName} + +
  • + `; + }, + filterable: true, + filterRemote: true, + search: { + fields: ['title'], + }, + selectable: true, + toggleLabel: (selected, el) => { + if (selected && 'id' in selected && $(el).hasClass('is-active')) { + return selected.title; + } + return defaultLabel; + }, + defaultLabel, + fieldName: $dropdown.data('fieldName'), + text: (milestone) => escape(milestone.title), + id: (milestone) => { + if (milestone !== undefined) { + if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) { + return milestone.name; + } + + return milestone.id; + } + }, + hidden: () => { + $selectBox.hide(); + // display:block overrides the hide-collapse rule + return $value.css('display', ''); + }, + opened: (e) => { + const $el = $(e.currentTarget); + if (options.handleClick) { + selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; + } + $('a.is-active', $el).removeClass('is-active'); + $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); + }, + vue: false, + clicked: (clickEvent) => { + const { e } = clickEvent; + let selected = clickEvent.selectedObj; + + if (!selected) return; + + if (options.handleClick) { + e.preventDefault(); + options.handleClick(selected); + return; + } + + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === page && page === 'projects:merge_requests:index'; + const isSelecting = selected.name !== selectedMilestone; + selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault; + + if ( + $dropdown.hasClass('js-filter-bulk-update') || + $dropdown.hasClass('js-issuable-form-dropdown') + ) { + e.preventDefault(); + return; + } + + if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + return Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } + + selected = $selectBox.find('input[type="hidden"]').val(); + + const data = {}; + data[abilityName] = {}; + data[abilityName].milestone_id = selected != null ? selected : null; + $loading.removeClass('gl-display-none'); + $dropdown.trigger('loading.gl.dropdown'); + return axios + .put(issueUpdateURL, data) + .then(({ data }) => { + $dropdown.trigger('loaded.gl.dropdown'); + $loading.addClass('gl-display-none'); + $selectBox.hide(); + $value.css('display', ''); + if (data.milestone != null) { + data.milestone.remaining = timeFor(data.milestone.due_date); + data.milestone.name = data.milestone.title; + $value.html( + data.milestone.expired + ? milestoneExpiredLinkTemplate({ + ...data.milestone, + remaining: sprintf(__('%{due_date} (Past due)'), { + due_date: dateInWords(parsePikadayDate(data.milestone.due_date)), + }), + }) + : milestoneLinkTemplate(data.milestone), + ); + return $sidebarCollapsedValue + .attr( + 'data-original-title', + `${data.milestone.name}
    ${data.milestone.remaining}`, + ) + .find('span') + .text(data.milestone.title); + } + $value.html(milestoneLinkNoneTemplate); + return $sidebarCollapsedValue + .attr('data-original-title', __('Milestone')) + .find('span') + .text(__('None')); + }) + .catch(() => { + $loading.addClass('gl-display-none'); + }); + }, + }); + }); + } +} + +window.MilestoneSelect = MilestoneSelect; diff --git a/app/assets/javascripts/milestones/milestone_utils.js b/app/assets/javascripts/milestones/milestone_utils.js deleted file mode 100644 index 3ae5e676138..00000000000 --- a/app/assets/javascripts/milestones/milestone_utils.js +++ /dev/null @@ -1,32 +0,0 @@ -import { parsePikadayDate } from '~/lib/utils/datetime_utility'; - -/** - * This method is to be used with `Array.prototype.sort` function - * where array contains milestones with `due_date`/`dueDate` and/or - * `expired` properties. - * This method sorts given milestone params based on their expiration - * status by putting expired milestones at the bottom and upcoming - * milestones at the top of the list. - * - * @param {object} milestoneA - * @param {object} milestoneB - */ -export function sortMilestonesByDueDate(milestoneA, milestoneB) { - const rawDueDateA = milestoneA.due_date || milestoneA.dueDate; - const rawDueDateB = milestoneB.due_date || milestoneB.dueDate; - const dueDateA = rawDueDateA ? parsePikadayDate(rawDueDateA) : null; - const dueDateB = rawDueDateB ? parsePikadayDate(rawDueDateB) : null; - const expiredA = milestoneA.expired || Date.now() > dueDateA?.getTime(); - const expiredB = milestoneB.expired || Date.now() > dueDateB?.getTime(); - - // Move all expired milestones to the bottom. - if (expiredA) return 1; - if (expiredB) return -1; - - // Move milestones without due dates just above expired milestones. - if (!dueDateA) return 1; - if (!dueDateB) return -1; - - // Sort by due date in ascending order. - return dueDateA - dueDateB; -} diff --git a/app/assets/javascripts/milestones/utils.js b/app/assets/javascripts/milestones/utils.js new file mode 100644 index 00000000000..3ae5e676138 --- /dev/null +++ b/app/assets/javascripts/milestones/utils.js @@ -0,0 +1,32 @@ +import { parsePikadayDate } from '~/lib/utils/datetime_utility'; + +/** + * This method is to be used with `Array.prototype.sort` function + * where array contains milestones with `due_date`/`dueDate` and/or + * `expired` properties. + * This method sorts given milestone params based on their expiration + * status by putting expired milestones at the bottom and upcoming + * milestones at the top of the list. + * + * @param {object} milestoneA + * @param {object} milestoneB + */ +export function sortMilestonesByDueDate(milestoneA, milestoneB) { + const rawDueDateA = milestoneA.due_date || milestoneA.dueDate; + const rawDueDateB = milestoneB.due_date || milestoneB.dueDate; + const dueDateA = rawDueDateA ? parsePikadayDate(rawDueDateA) : null; + const dueDateB = rawDueDateB ? parsePikadayDate(rawDueDateB) : null; + const expiredA = milestoneA.expired || Date.now() > dueDateA?.getTime(); + const expiredB = milestoneB.expired || Date.now() > dueDateB?.getTime(); + + // Move all expired milestones to the bottom. + if (expiredA) return 1; + if (expiredB) return -1; + + // Move milestones without due dates just above expired milestones. + if (!dueDateA) return 1; + if (!dueDateB) return -1; + + // Sort by due date in ascending order. + return dueDateA - dueDateB; +} -- cgit v1.2.3