diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-01 21:15:19 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-01 21:15:19 +0300 |
commit | 5fc2d78fb96b0fd50dfb737190fd411033b3c3ab (patch) | |
tree | 24cb469e61661c923a1398505b2bb928612f80d4 /app/assets/javascripts/milestones | |
parent | 66629d156e2420269ed53eff3dca0912cfe848e2 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/milestones')
9 files changed, 693 insertions, 0 deletions
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 @@ +<script> +import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; + +import { redirectTo } from '~/lib/utils/url_utility'; +import { __, n__, s__, sprintf } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + GlModal, + }, + directives: { + SafeHtml, + }, + props: { + issueCount: { + type: Number, + required: true, + }, + mergeRequestCount: { + type: Number, + required: true, + }, + milestoneId: { + type: Number, + required: true, + }, + milestoneTitle: { + type: String, + required: true, + }, + milestoneUrl: { + type: String, + required: true, + }, + }, + computed: { + text() { + const milestoneTitle = sprintf('<strong>%{milestoneTitle}</strong>', { + milestoneTitle: this.milestoneTitle, + }); + + if (this.issueCount === 0 && this.mergeRequestCount === 0) { + return sprintf( + s__(`Milestones| +You’re about to permanently delete the milestone %{milestoneTitle}. +This milestone is not currently used in any issues or merge requests.`), + { + milestoneTitle, + }, + false, + ); + } + + return sprintf( + s__(`Milestones| +You’re about to permanently delete the milestone %{milestoneTitle} and remove it from %{issuesWithCount} and %{mergeRequestsWithCount}. +Once deleted, it cannot be undone or recovered.`), + { + milestoneTitle, + issuesWithCount: n__('%d issue', '%d issues', this.issueCount), + mergeRequestsWithCount: n__( + '%d merge request', + '%d merge requests', + this.mergeRequestCount, + ), + }, + false, + ); + }, + title() { + return sprintf(s__('Milestones|Delete milestone %{milestoneTitle}?'), { + milestoneTitle: this.milestoneTitle, + }); + }, + }, + methods: { + onSubmit() { + eventHub.$emit('deleteMilestoneModal.requestStarted', this.milestoneUrl); + + return axios + .delete(this.milestoneUrl) + .then((response) => { + eventHub.$emit('deleteMilestoneModal.requestFinished', { + milestoneUrl: this.milestoneUrl, + successful: true, + }); + + // follow the rediect to milestones overview page + redirectTo(response.request.responseURL); + }) + .catch((error) => { + eventHub.$emit('deleteMilestoneModal.requestFinished', { + milestoneUrl: this.milestoneUrl, + successful: false, + }); + + if (error.response && error.response.status === 404) { + createFlash({ + message: sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), { + milestoneTitle: this.milestoneTitle, + }), + }); + } else { + createFlash({ + message: sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), { + milestoneTitle: this.milestoneTitle, + }), + }); + } + throw error; + }); + }, + }, + primaryProps: { + text: s__('Milestones|Delete milestone'), + attributes: [{ variant: 'danger' }, { category: 'primary' }], + }, + cancelProps: { + text: __('Cancel'), + }, +}; +</script> + +<template> + <gl-modal + modal-id="delete-milestone-modal" + :title="title" + :action-primary="$options.primaryProps" + :action-cancel="$options.cancelProps" + @primary="onSubmit" + > + <p v-safe-html="text"></p> + </gl-modal> +</template> 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 @@ +<script> +import { 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'; + +export default { + components: { + GlModal, + }, + data() { + return { + milestoneTitle: '', + url: '', + groupName: '', + currentButton: null, + visible: false, + }; + }, + computed: { + title() { + return sprintf(s__('Milestones|Promote %{milestoneTitle} to group milestone?'), { + milestoneTitle: this.milestoneTitle, + }); + }, + text() { + return sprintf( + s__(`Milestones|Promoting %{milestoneTitle} will make it available for all projects inside %{groupName}. + Existing project milestones with the same title will be merged.`), + { milestoneTitle: this.milestoneTitle, groupName: this.groupName }, + ); + }, + }, + mounted() { + this.getButtons().forEach((button) => { + button.addEventListener('click', this.onPromoteButtonClick); + button.removeAttribute('disabled'); + }); + }, + beforeDestroy() { + this.getButtons().forEach((button) => { + button.removeEventListener('click', this.onPromoteButtonClick); + }); + }, + methods: { + onPromoteButtonClick({ currentTarget }) { + const { milestoneTitle, url, groupName } = currentTarget.dataset; + currentTarget.setAttribute('disabled', ''); + this.visible = true; + this.milestoneTitle = milestoneTitle; + this.url = url; + this.groupName = groupName; + this.currentButton = currentTarget; + }, + getButtons() { + return document.querySelectorAll('.js-promote-project-milestone-button'); + }, + onSubmit() { + return axios + .post(this.url, { params: { format: 'json' } }) + .then((response) => { + visitUrl(response.data.url); + }) + .catch((error) => { + createFlash({ + message: error, + }); + }) + .finally(() => { + this.visible = false; + }); + }, + onClose() { + this.visible = false; + if (this.currentButton) { + this.currentButton.removeAttribute('disabled'); + } + }, + }, + primaryAction: { + text: s__('Milestones|Promote Milestone'), + attributes: [{ variant: 'warning' }], + }, + cancelAction: { + text: __('Cancel'), + attributes: [], + }, +}; +</script> +<template> + <gl-modal + :visible="visible" + modal-id="promote-milestone-modal" + :action-primary="$options.primaryAction" + :action-cancel="$options.cancelAction" + :title="title" + @primary="onSubmit" + @hide="onClose" + > + <p>{{ text }}</p> + <p>{{ s__('Milestones|This action cannot be reversed.') }}</p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/milestones/delete_milestone_modal_init.js b/app/assets/javascripts/milestones/delete_milestone_modal_init.js new file mode 100644 index 00000000000..3aeff2db2e0 --- /dev/null +++ b/app/assets/javascripts/milestones/delete_milestone_modal_init.js @@ -0,0 +1,75 @@ +import Vue from 'vue'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import Translate from '~/vue_shared/translate'; +import DeleteMilestoneModal from './components/delete_milestone_modal.vue'; +import eventHub from './event_hub'; + +export default () => { + 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/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/form.js b/app/assets/javascripts/milestones/form.js new file mode 100644 index 00000000000..40d45d7deb8 --- /dev/null +++ b/app/assets/javascripts/milestones/form.js @@ -0,0 +1,22 @@ +import $ from 'jquery'; +import initDatePicker from '~/behaviors/date_picker'; +import GLForm from '~/gl_form'; +import ZenMode from '~/zen_mode'; + +export default (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, + }); +}; diff --git a/app/assets/javascripts/milestones/init_milestones_show.js b/app/assets/javascripts/milestones/init_milestones_show.js new file mode 100644 index 00000000000..8939e1535c1 --- /dev/null +++ b/app/assets/javascripts/milestones/init_milestones_show.js @@ -0,0 +1,11 @@ +/* eslint-disable no-new */ + +import Milestone from '~/milestones/milestone'; +import Sidebar from '~/right_sidebar'; +import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; + +export default () => { + new Milestone(); + new Sidebar(); + new MountMilestoneSidebar(); +}; diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js new file mode 100644 index 00000000000..2c43bed412e --- /dev/null +++ b/app/assets/javascripts/milestones/milestone.js @@ -0,0 +1,49 @@ +import $ from 'jquery'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; + +export default class Milestone { + constructor() { + this.bindTabsSwitching(); + this.loadInitialTab(); + } + + bindTabsSwitching() { + return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => { + const $target = $(e.target); + + window.location.hash = $target.attr('href'); + this.loadTab($target); + }); + } + + loadInitialTab() { + const $target = $(`.js-milestone-tabs a:not(.active)[href="${window.location.hash}"]`); + + if ($target.length) { + $target.tab('show'); + } else { + this.loadTab($('.js-milestone-tabs a.active')); + } + } + // eslint-disable-next-line class-methods-use-this + loadTab($target) { + const endpoint = $target.data('endpoint'); + const tabElId = $target.attr('href'); + + if (endpoint && !$target.hasClass('is-loaded')) { + axios + .get(endpoint) + .then(({ data }) => { + $(tabElId).html(data.html); + $target.addClass('is-loaded'); + }) + .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..91780d5ee01 --- /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/milestone_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( + '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>', + ); + milestoneExpiredLinkTemplate = template( + '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %> (Past due)</a>', + ); + milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`; + } + 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 ` + <li data-milestone-id="${escape(milestoneName)}"> + <a href='#' class='dropdown-menu-milestone-link'> + ${milestoneDisplayName} + </a> + </li> + `; + }, + 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}<br />${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/promote_milestone_modal_init.js b/app/assets/javascripts/milestones/promote_milestone_modal_init.js new file mode 100644 index 00000000000..5472b8c684f --- /dev/null +++ b/app/assets/javascripts/milestones/promote_milestone_modal_init.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import PromoteMilestoneModal from './components/promote_milestone_modal.vue'; + +Vue.use(Translate); + +export default () => { + const promoteMilestoneModal = document.getElementById('promote-milestone-modal'); + if (!promoteMilestoneModal) { + return null; + } + + return new Vue({ + el: promoteMilestoneModal, + render(createElement) { + return createElement(PromoteMilestoneModal); + }, + }); +}; |