diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-01-20 12:16:11 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-01-20 12:16:11 +0300 |
commit | edaa33dee2ff2f7ea3fac488d41558eb5f86d68c (patch) | |
tree | 11f143effbfeba52329fb7afbd05e6e2a3790241 /app/assets/javascripts/issues | |
parent | d8a5691316400a0f7ec4f83832698f1988eb27c1 (diff) |
Add latest changes from gitlab-org/gitlab@14-7-stable-eev14.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/issues')
38 files changed, 3098 insertions, 270 deletions
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index b7b123dfd5f..4b9a42da178 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -19,6 +19,12 @@ export const IssuableType = { Alert: 'alert', }; +export const IssueType = { + Issue: 'issue', + Incident: 'incident', + TestCase: 'test_case', +}; + export const WorkspaceType = { project: 'project', group: 'group', diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js new file mode 100644 index 00000000000..5d36396bc6e --- /dev/null +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -0,0 +1,566 @@ +import { debounce } from 'lodash'; +import { + init as initConfidentialMergeRequest, + isConfidentialIssue, + canCreateConfidentialMergeRequest, +} from '~/confidential_merge_request'; +import confidentialMergeRequestState from '~/confidential_merge_request/state'; +import DropLab from '~/filtered_search/droplab/drop_lab_deprecated'; +import ISetter from '~/filtered_search/droplab/plugins/input_setter'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __, sprintf } from '~/locale'; + +// Todo: Remove this when fixing issue in input_setter plugin +const InputSetter = { ...ISetter }; + +const CREATE_MERGE_REQUEST = 'create-mr'; +const CREATE_BRANCH = 'create-branch'; + +function createEndpoint(projectPath, endpoint) { + if (canCreateConfidentialMergeRequest()) { + return endpoint.replace( + projectPath, + confidentialMergeRequestState.selectedProject.pathWithNamespace, + ); + } + + return endpoint; +} + +export default class CreateMergeRequestDropdown { + constructor(wrapperEl) { + this.wrapperEl = wrapperEl; + this.availableButton = this.wrapperEl.querySelector('.available'); + this.branchInput = this.wrapperEl.querySelector('.js-branch-name'); + this.branchMessage = this.wrapperEl.querySelector('.js-branch-message'); + this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request'); + this.createMergeRequestLoading = this.createMergeRequestButton.querySelector('.js-spinner'); + this.createTargetButton = this.wrapperEl.querySelector('.js-create-target'); + this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu'); + this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle'); + this.refInput = this.wrapperEl.querySelector('.js-ref'); + this.refMessage = this.wrapperEl.querySelector('.js-ref-message'); + this.unavailableButton = this.wrapperEl.querySelector('.unavailable'); + this.unavailableButtonSpinner = this.unavailableButton.querySelector('.gl-spinner'); + this.unavailableButtonText = this.unavailableButton.querySelector('.text'); + + this.branchCreated = false; + this.branchIsValid = true; + this.canCreatePath = this.wrapperEl.dataset.canCreatePath; + this.createBranchPath = this.wrapperEl.dataset.createBranchPath; + this.createMrPath = this.wrapperEl.dataset.createMrPath; + this.droplabInitialized = false; + this.isCreatingBranch = false; + this.isCreatingMergeRequest = false; + this.isGettingRef = false; + this.refCancelToken = null; + this.mergeRequestCreated = false; + this.refDebounce = debounce((value, target) => this.getRef(value, target), 500); + this.refIsValid = true; + this.refsPath = this.wrapperEl.dataset.refsPath; + this.suggestedRef = this.refInput.value; + this.projectPath = this.wrapperEl.dataset.projectPath; + this.projectId = this.wrapperEl.dataset.projectId; + + // These regexps are used to replace + // a backend generated new branch name and its source (ref) + // with user's inputs. + this.regexps = { + branch: { + createBranchPath: new RegExp('(branch_name=)(.+?)(?=&issue)'), + createMrPath: new RegExp('(branch_name=)(.+?)(?=&ref)'), + }, + ref: { + createBranchPath: new RegExp('(ref=)(.+?)$'), + createMrPath: new RegExp('(ref=)(.+?)$'), + }, + }; + + this.init(); + + if (isConfidentialIssue()) { + this.createMergeRequestButton.setAttribute( + 'data-dropdown-trigger', + '#create-merge-request-dropdown', + ); + initConfidentialMergeRequest(); + } + } + + available() { + this.availableButton.classList.remove('hidden'); + this.unavailableButton.classList.add('hidden'); + } + + bindEvents() { + this.createMergeRequestButton.addEventListener( + 'click', + this.onClickCreateMergeRequestButton.bind(this), + ); + this.createTargetButton.addEventListener( + 'click', + this.onClickCreateMergeRequestButton.bind(this), + ); + this.branchInput.addEventListener('input', this.onChangeInput.bind(this)); + this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this)); + this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this)); + // Detect for example when user pastes ref using the mouse + this.refInput.addEventListener('input', this.onChangeInput.bind(this)); + // Detect for example when user presses right arrow to apply the suggested ref + this.refInput.addEventListener('keyup', this.onChangeInput.bind(this)); + // Detect when user clicks inside the input to apply the suggested ref + this.refInput.addEventListener('click', this.onChangeInput.bind(this)); + // Detect when user clicks outside the input to apply the suggested ref + this.refInput.addEventListener('blur', this.onChangeInput.bind(this)); + // Detect when user presses tab to apply the suggested ref + this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this)); + } + + checkAbilityToCreateBranch() { + this.setUnavailableButtonState(); + + axios + .get(this.canCreatePath) + .then(({ data }) => { + this.setUnavailableButtonState(false); + + if (data.can_create_branch) { + this.available(); + this.enable(); + this.updateBranchName(data.suggested_branch_name); + + if (!this.droplabInitialized) { + this.droplabInitialized = true; + this.initDroplab(); + this.bindEvents(); + } + } else { + this.hide(); + } + }) + .catch(() => { + this.unavailable(); + this.disable(); + createFlash({ + message: __('Failed to check related branches.'), + }); + }); + } + + createBranch() { + this.isCreatingBranch = true; + + return axios + .post(createEndpoint(this.projectPath, this.createBranchPath), { + confidential_issue_project_id: canCreateConfidentialMergeRequest() ? this.projectId : null, + }) + .then(({ data }) => { + this.branchCreated = true; + window.location.href = data.url; + }) + .catch(() => + createFlash({ + message: __('Failed to create a branch for this issue. Please try again.'), + }), + ); + } + + createMergeRequest() { + this.isCreatingMergeRequest = true; + + return axios + .post(this.createMrPath, { + target_project_id: canCreateConfidentialMergeRequest() + ? confidentialMergeRequestState.selectedProject.id + : null, + }) + .then(({ data }) => { + this.mergeRequestCreated = true; + window.location.href = data.url; + }) + .catch(() => + createFlash({ + message: __('Failed to create merge request. Please try again.'), + }), + ); + } + + disable() { + this.disableCreateAction(); + } + + setLoading(loading) { + this.createMergeRequestLoading.classList.toggle('gl-display-none', !loading); + } + + disableCreateAction() { + this.createMergeRequestButton.classList.add('disabled'); + this.createMergeRequestButton.setAttribute('disabled', 'disabled'); + + this.createTargetButton.classList.add('disabled'); + this.createTargetButton.setAttribute('disabled', 'disabled'); + } + + enable() { + if (isConfidentialIssue() && !canCreateConfidentialMergeRequest()) return; + + this.createMergeRequestButton.classList.remove('disabled'); + this.createMergeRequestButton.removeAttribute('disabled'); + + this.createTargetButton.classList.remove('disabled'); + this.createTargetButton.removeAttribute('disabled'); + } + + static findByValue(objects, ref, returnFirstMatch = false) { + if (!objects || !objects.length) return false; + if (objects.indexOf(ref) > -1) return ref; + if (returnFirstMatch) return objects.find((item) => new RegExp(`^${ref}`).test(item)); + + return false; + } + + getDroplabConfig() { + return { + addActiveClassToDropdownButton: true, + InputSetter: [ + { + input: this.createMergeRequestButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, + { + input: this.createMergeRequestButton, + valueAttribute: 'data-text', + }, + { + input: this.createTargetButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, + { + input: this.createTargetButton, + valueAttribute: 'data-text', + }, + ], + hideOnClick: false, + }; + } + + static getInputSelectedText(input) { + const start = input.selectionStart; + const end = input.selectionEnd; + + return input.value.substr(start, end - start); + } + + getRef(ref, target = 'all') { + if (!ref) return false; + + this.refCancelToken = axios.CancelToken.source(); + + return axios + .get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`, { + cancelToken: this.refCancelToken.token, + }) + .then(({ data }) => { + const branches = data[Object.keys(data)[0]]; + const tags = data[Object.keys(data)[1]]; + let result; + + if (target === 'branch') { + result = CreateMergeRequestDropdown.findByValue(branches, ref); + } else { + result = + CreateMergeRequestDropdown.findByValue(branches, ref, true) || + CreateMergeRequestDropdown.findByValue(tags, ref, true); + this.suggestedRef = result; + } + + this.isGettingRef = false; + + return this.updateInputState(target, ref, result); + }) + .catch((thrown) => { + if (axios.isCancel(thrown)) { + return false; + } + this.unavailable(); + this.disable(); + createFlash({ + message: __('Failed to get ref.'), + }); + + this.isGettingRef = false; + + return false; + }); + } + + getTargetData(target) { + return { + input: this[`${target}Input`], + message: this[`${target}Message`], + }; + } + + hide() { + this.wrapperEl.classList.add('hidden'); + } + + init() { + this.checkAbilityToCreateBranch(); + } + + initDroplab() { + this.droplab = new DropLab(); + + this.droplab.init( + this.dropdownToggle, + this.dropdownList, + [InputSetter], + this.getDroplabConfig(), + ); + } + + inputsAreValid() { + return this.branchIsValid && this.refIsValid; + } + + isBusy() { + return ( + this.isCreatingMergeRequest || + this.mergeRequestCreated || + this.isCreatingBranch || + this.branchCreated || + this.isGettingRef + ); + } + + onChangeInput(event) { + this.disable(); + let target; + let value; + + // User changed input, cancel to prevent previous request from interfering + if (this.refCancelToken !== null) { + this.refCancelToken.cancel(); + } + + if (event.target === this.branchInput) { + target = 'branch'; + ({ value } = this.branchInput); + } else if (event.target === this.refInput) { + target = 'ref'; + if (event.target === document.activeElement) { + value = + event.target.value.slice(0, event.target.selectionStart) + + event.target.value.slice(event.target.selectionEnd); + } else { + value = event.target.value; + } + } else { + return false; + } + + if (this.isGettingRef) return false; + + // `ENTER` key submits the data. + if (event.keyCode === 13 && this.inputsAreValid()) { + event.preventDefault(); + return this.createMergeRequestButton.click(); + } + + // If the input is empty, use the original value generated by the backend. + if (!value) { + this.createBranchPath = this.wrapperEl.dataset.createBranchPath; + this.createMrPath = this.wrapperEl.dataset.createMrPath; + + if (target === 'branch') { + this.branchIsValid = true; + } else { + this.refIsValid = true; + } + + this.enable(); + this.showAvailableMessage(target); + this.refDebounce(value, target); + return true; + } + + this.showCheckingMessage(target); + this.refDebounce(value, target); + + return true; + } + + onClickCreateMergeRequestButton(event) { + let xhr = null; + event.preventDefault(); + + if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) { + this.droplab.hooks.forEach((hook) => hook.list.toggle()); + + return; + } + + if (this.isBusy()) { + return; + } + + if (event.target.dataset.action === CREATE_MERGE_REQUEST) { + xhr = this.createMergeRequest(); + } else if (event.target.dataset.action === CREATE_BRANCH) { + xhr = this.createBranch(); + } + + xhr.catch(() => { + this.isCreatingMergeRequest = false; + this.isCreatingBranch = false; + + this.enable(); + this.setLoading(false); + }); + + this.setLoading(true); + this.disable(); + } + + onClickSetFocusOnBranchNameInput() { + this.branchInput.focus(); + } + + // `TAB` autocompletes the source. + static processTab(event) { + if (event.keyCode !== 9 || this.isGettingRef) return; + + const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput); + + // if nothing selected, we don't need to autocomplete anything. Do the default TAB action. + // If a user manually selected text, don't autocomplete anything. Do the default TAB action. + if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return; + + event.preventDefault(); + const caretPositionEnd = this.refInput.value.length; + this.refInput.setSelectionRange(caretPositionEnd, caretPositionEnd); + } + + removeMessage(target) { + const { input, message } = this.getTargetData(target); + const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline']; + const messageClasses = ['text-muted', 'text-danger', 'text-success']; + + inputClasses.forEach((cssClass) => input.classList.remove(cssClass)); + messageClasses.forEach((cssClass) => message.classList.remove(cssClass)); + message.style.display = 'none'; + } + + setUnavailableButtonState(isLoading = true) { + if (isLoading) { + this.unavailableButtonSpinner.classList.remove('hide'); + this.unavailableButtonText.textContent = __('Checking branch availability...'); + } else { + this.unavailableButtonSpinner.classList.add('hide'); + this.unavailableButtonText.textContent = __('New branch unavailable'); + } + } + + showAvailableMessage(target) { + const { input, message } = this.getTargetData(target); + const text = target === 'branch' ? __('Branch name') : __('Source'); + + this.removeMessage(target); + input.classList.add('gl-field-success-outline'); + message.classList.add('text-success'); + message.textContent = sprintf(__('%{text} is available'), { text }); + message.style.display = 'inline-block'; + } + + showCheckingMessage(target) { + const { message } = this.getTargetData(target); + const text = target === 'branch' ? __('branch name') : __('source'); + + this.removeMessage(target); + message.classList.add('text-muted'); + message.textContent = sprintf(__('Checking %{text} availability…'), { text }); + message.style.display = 'inline-block'; + } + + showNotAvailableMessage(target) { + const { input, message } = this.getTargetData(target); + const text = + target === 'branch' ? __('Branch is already taken') : __('Source is not available'); + + this.removeMessage(target); + input.classList.add('gl-field-error-outline'); + message.classList.add('text-danger'); + message.textContent = text; + message.style.display = 'inline-block'; + } + + unavailable() { + this.availableButton.classList.add('hidden'); + this.unavailableButton.classList.remove('hidden'); + } + + updateBranchName(suggestedBranchName) { + this.branchInput.value = suggestedBranchName; + this.updateCreatePaths('branch', suggestedBranchName); + } + + updateInputState(target, ref, result) { + // target - 'branch' or 'ref' - which the input field we are searching a ref for. + // ref - string - what a user typed. + // result - string - what has been found on backend. + + // If a found branch equals exact the same text a user typed, + // that means a new branch cannot be created as it already exists. + if (ref === result) { + if (target === 'branch') { + this.branchIsValid = false; + this.showNotAvailableMessage('branch'); + } else { + this.refIsValid = true; + this.refInput.dataset.value = ref; + this.showAvailableMessage('ref'); + this.updateCreatePaths(target, ref); + } + } else if (target === 'branch') { + this.branchIsValid = true; + this.showAvailableMessage('branch'); + this.updateCreatePaths(target, ref); + } else { + this.refIsValid = false; + this.refInput.dataset.value = ref; + this.disableCreateAction(); + this.showNotAvailableMessage('ref'); + + // Show ref hint. + if (result) { + this.refInput.value = result; + this.refInput.setSelectionRange(ref.length, result.length); + } + } + + if (this.inputsAreValid()) { + this.enable(); + } else { + this.disableCreateAction(); + } + } + + // target - 'branch' or 'ref' + // ref - string - the new value to use as branch or ref + updateCreatePaths(target, ref) { + const pathReplacement = `$1${encodeURIComponent(ref)}`; + + this.createBranchPath = this.createBranchPath.replace( + this.regexps[target].createBranchPath, + pathReplacement, + ); + this.createMrPath = this.createMrPath.replace( + this.regexps[target].createMrPath, + pathReplacement, + ); + } +} diff --git a/app/assets/javascripts/issues/form.js b/app/assets/javascripts/issues/form.js deleted file mode 100644 index 33371d065f9..00000000000 --- a/app/assets/javascripts/issues/form.js +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable no-new */ - -import $ from 'jquery'; -import IssuableForm from 'ee_else_ce/issuable/issuable_form'; -import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import GLForm from '~/gl_form'; -import { initTitleSuggestions, initTypePopover } from '~/issues/new'; -import LabelsSelect from '~/labels/labels_select'; -import MilestoneSelect from '~/milestones/milestone_select'; -import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; - -export default () => { - new ShortcutsNavigation(); - new GLForm($('.issue-form')); - new IssuableForm($('.issue-form')); - new LabelsSelect(); - new MilestoneSelect(); - new IssuableTemplateSelectors({ - warnTemplateOverride: true, - }); - - initTitleSuggestions(); - initTypePopover(); -}; diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js new file mode 100644 index 00000000000..2ee9ac2a682 --- /dev/null +++ b/app/assets/javascripts/issues/index.js @@ -0,0 +1,88 @@ +import $ from 'jquery'; +import IssuableForm from 'ee_else_ce/issuable/issuable_form'; +import loadAwardsHandler from '~/awards_handler'; +import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; +import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; +import GLForm from '~/gl_form'; +import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; +import { IssueType } from '~/issues/constants'; +import Issue from '~/issues/issue'; +import { initTitleSuggestions, initTypePopover } from '~/issues/new'; +import { initRelatedMergeRequests } from '~/issues/related_merge_requests'; +import { + initHeaderActions, + initIncidentApp, + initIssueApp, + initSentryErrorStackTrace, +} from '~/issues/show'; +import { parseIssuableData } from '~/issues/show/utils/parse_data'; +import LabelsSelect from '~/labels/labels_select'; +import MilestoneSelect from '~/milestones/milestone_select'; +import initNotesApp from '~/notes'; +import { store } from '~/notes/stores'; +import ZenMode from '~/zen_mode'; +import FilteredSearchServiceDesk from './filtered_search_service_desk'; + +export function initFilteredSearchServiceDesk() { + if (document.querySelector('.filtered-search')) { + const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, + ); + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + filteredSearchManager.setup(); + } +} + +export function initForm() { + new GLForm($('.issue-form')); // eslint-disable-line no-new + new IssuableForm($('.issue-form')); // eslint-disable-line no-new + new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new + new LabelsSelect(); // eslint-disable-line no-new + new MilestoneSelect(); // eslint-disable-line no-new + new ShortcutsNavigation(); // eslint-disable-line no-new + + initTitleSuggestions(); + initTypePopover(); +} + +export function initShow() { + const el = document.getElementById('js-issuable-app'); + + if (!el) { + return; + } + + const { issueType, ...issuableData } = parseIssuableData(el); + + if (issueType === IssueType.Incident) { + initIncidentApp(issuableData); + initHeaderActions(store, IssueType.Incident); + } else { + initIssueApp(issuableData, store); + initHeaderActions(store); + } + + new Issue(); // eslint-disable-line no-new + new ShortcutsIssuable(); // eslint-disable-line no-new + new ZenMode(); // eslint-disable-line no-new + initIssuableHeaderWarnings(store); + initIssuableSidebar(); + initNotesApp(); + initRelatedMergeRequests(); + initSentryErrorStackTrace(); + + const awardEmojiEl = document.getElementById('js-vue-awards-block'); + + if (awardEmojiEl) { + import('~/emoji/awards_app') + .then((m) => m.default(awardEmojiEl)) + .catch(() => {}); + } else { + loadAwardsHandler(); + } + + import(/* webpackChunkName: 'design_management' */ '~/design_management') + .then((module) => module.default()) + .catch(() => {}); +} diff --git a/app/assets/javascripts/issues/init_filtered_search_service_desk.js b/app/assets/javascripts/issues/init_filtered_search_service_desk.js deleted file mode 100644 index 1901802c11c..00000000000 --- a/app/assets/javascripts/issues/init_filtered_search_service_desk.js +++ /dev/null @@ -1,11 +0,0 @@ -import FilteredSearchServiceDesk from './filtered_search_service_desk'; - -export function initFilteredSearchServiceDesk() { - if (document.querySelector('.filtered-search')) { - const supportBotData = JSON.parse( - document.querySelector('.js-service-desk-issues').dataset.supportBot, - ); - const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); - filteredSearchManager.setup(); - } -} diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js index c471875654b..8e27f547b5c 100644 --- a/app/assets/javascripts/issues/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import { joinPaths } from '~/lib/utils/url_utility'; -import CreateMergeRequestDropdown from '~/create_merge_request_dropdown'; import createFlash from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; +import CreateMergeRequestDropdown from './create_merge_request_dropdown'; export default class Issue { constructor() { diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue new file mode 100644 index 00000000000..aece7372182 --- /dev/null +++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue @@ -0,0 +1,104 @@ +<script> +import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + dateInWords, + getTimeRemainingInWords, + isInFuture, + isInPast, + isToday, +} from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; + +export default { + components: { + GlLink, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + issue: { + type: Object, + required: true, + }, + }, + computed: { + milestoneDate() { + if (this.issue.milestone?.dueDate) { + const { dueDate, startDate } = this.issue.milestone; + const date = dateInWords(new Date(dueDate), true); + const remainingTime = this.milestoneRemainingTime(dueDate, startDate); + return `${date} (${remainingTime})`; + } + return __('Milestone'); + }, + milestoneLink() { + return this.issue.milestone.webPath || this.issue.milestone.webUrl; + }, + dueDate() { + return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true); + }, + showDueDateInRed() { + return isInPast(new Date(this.issue.dueDate)) && !this.issue.closedAt; + }, + timeEstimate() { + return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate; + }, + }, + methods: { + milestoneRemainingTime(dueDate, startDate) { + const due = new Date(dueDate); + const start = new Date(startDate); + + if (dueDate && isInPast(due)) { + return __('Past due'); + } else if (dueDate && isToday(due)) { + return __('Today'); + } else if (startDate && isInFuture(start)) { + return __('Upcoming'); + } else if (dueDate) { + return getTimeRemainingInWords(due); + } + return ''; + }, + }, +}; +</script> + +<template> + <span> + <span + v-if="issue.milestone" + class="issuable-milestone gl-mr-3" + data-testid="issuable-milestone" + > + <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate"> + <gl-icon name="clock" /> + {{ issue.milestone.title }} + </gl-link> + </span> + <span + v-if="issue.dueDate" + v-gl-tooltip + class="issuable-due-date gl-mr-3" + :class="{ 'gl-text-red-500': showDueDateInRed }" + :title="__('Due date')" + data-testid="issuable-due-date" + > + <gl-icon name="calendar" /> + {{ dueDate }} + </span> + <span + v-if="timeEstimate" + v-gl-tooltip + class="gl-mr-3" + :title="__('Estimate')" + data-testid="time-estimate" + > + <gl-icon name="timer" /> + {{ timeEstimate }} + </span> + <slot></slot> + </span> +</template> diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue new file mode 100644 index 00000000000..8b15e801f02 --- /dev/null +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -0,0 +1,821 @@ +<script> +import { + GlButton, + GlEmptyState, + GlFilteredSearchToken, + GlIcon, + GlLink, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { orderBy } from 'lodash'; +import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; +import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import { TYPE_USER } from '~/graphql_shared/constants'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { ITEM_TYPE } from '~/groups/constants'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; +import { + CREATED_DESC, + i18n, + MAX_LIST_SIZE, + PAGE_SIZE, + PARAM_DUE_DATE, + PARAM_SORT, + PARAM_STATE, + RELATIVE_POSITION_ASC, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_TYPE, + UPDATED_DESC, + urlSortParams, +} from '~/issues/list/constants'; +import { + convertToApiParams, + convertToSearchQuery, + convertToUrlParams, + getDueDateValue, + getFilterTokens, + getInitialPageParams, + getSortKey, + getSortOptions, +} from '~/issues/list/utils'; +import axios from '~/lib/utils/axios_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; +import { + DEFAULT_NONE_ANY, + OPERATOR_IS_ONLY, + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_AUTHOR, + TOKEN_TITLE_CONFIDENTIAL, + TOKEN_TITLE_LABEL, + TOKEN_TITLE_MILESTONE, + TOKEN_TITLE_MY_REACTION, + TOKEN_TITLE_RELEASE, + TOKEN_TITLE_TYPE, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import eventHub from '../eventhub'; +import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; +import searchLabelsQuery from '../queries/search_labels.query.graphql'; +import searchMilestonesQuery from '../queries/search_milestones.query.graphql'; +import searchUsersQuery from '../queries/search_users.query.graphql'; +import NewIssueDropdown from './new_issue_dropdown.vue'; + +const AuthorToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'); +const EmojiToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); +const LabelToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); +const MilestoneToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); +const ReleaseToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'); + +export default { + i18n, + IssuableListTabs, + components: { + CsvImportExportButtons, + GlButton, + GlEmptyState, + GlIcon, + GlLink, + GlSprintf, + IssuableByEmail, + IssuableList, + IssueCardTimeInfo, + NewIssueDropdown, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + autocompleteAwardEmojisPath: { + default: '', + }, + calendarPath: { + default: '', + }, + canBulkUpdate: { + default: false, + }, + emptyStateSvgPath: { + default: '', + }, + exportCsvPath: { + default: '', + }, + fullPath: { + default: '', + }, + hasAnyIssues: { + default: false, + }, + hasAnyProjects: { + default: false, + }, + hasBlockedIssuesFeature: { + default: false, + }, + hasIssueWeightsFeature: { + default: false, + }, + hasMultipleIssueAssigneesFeature: { + default: false, + }, + initialEmail: { + default: '', + }, + isAnonymousSearchDisabled: { + default: false, + }, + isIssueRepositioningDisabled: { + default: false, + }, + isProject: { + default: false, + }, + isSignedIn: { + default: false, + }, + jiraIntegrationPath: { + default: '', + }, + newIssuePath: { + default: '', + }, + releasesPath: { + default: '', + }, + rssPath: { + default: '', + }, + showNewIssueLink: { + default: false, + }, + signInPath: { + default: '', + }, + }, + props: { + eeSearchTokens: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + const state = getParameterByName(PARAM_STATE); + const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; + let sortKey = getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey; + + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + sortKey = defaultSortKey; + } + + const isSearchDisabled = + this.isAnonymousSearchDisabled && + !this.isSignedIn && + window.location.search.includes('search='); + + if (isSearchDisabled) { + this.showAnonymousSearchingMessage(); + } + + return { + dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)), + exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), + filterTokens: isSearchDisabled ? [] : getFilterTokens(window.location.search), + issues: [], + issuesCounts: {}, + issuesError: null, + pageInfo: {}, + pageParams: getInitialPageParams(sortKey), + showBulkEditSidebar: false, + sortKey, + state: state || IssuableStates.Opened, + }; + }, + apollo: { + issues: { + query: getIssuesQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data[this.namespace]?.issues.nodes ?? []; + }, + result({ data }) { + this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {}; + this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingIssues; + Sentry.captureException(error); + }, + skip() { + return !this.hasAnyIssues; + }, + debounce: 200, + }, + issuesCounts: { + query: getIssuesCountsQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data[this.namespace] ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingCounts; + Sentry.captureException(error); + }, + skip() { + return !this.hasAnyIssues; + }, + debounce: 200, + context: { + isSingleRequest: true, + }, + }, + }, + computed: { + queryVariables() { + return { + fullPath: this.fullPath, + isProject: this.isProject, + isSignedIn: this.isSignedIn, + search: this.searchQuery, + sort: this.sortKey, + state: this.state, + ...this.pageParams, + ...this.apiFilterParams, + }; + }, + namespace() { + return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; + }, + hasSearch() { + return this.searchQuery || Object.keys(this.urlFilterParams).length; + }, + isBulkEditButtonDisabled() { + return this.showBulkEditSidebar || !this.issues.length; + }, + isManualOrdering() { + return this.sortKey === RELATIVE_POSITION_ASC; + }, + isOpenTab() { + return this.state === IssuableStates.Opened; + }, + showCsvButtons() { + return this.isProject && this.isSignedIn; + }, + showNewIssueDropdown() { + return !this.isProject && this.hasAnyProjects; + }, + apiFilterParams() { + return convertToApiParams(this.filterTokens); + }, + urlFilterParams() { + return convertToUrlParams(this.filterTokens); + }, + searchQuery() { + return convertToSearchQuery(this.filterTokens) || undefined; + }, + searchTokens() { + const preloadedAuthors = []; + + if (gon.current_user_id) { + preloadedAuthors.push({ + id: convertToGraphQLId(TYPE_USER, gon.current_user_id), + name: gon.current_user_fullname, + username: gon.current_username, + avatar_url: gon.current_user_avatar_url, + }); + } + + const tokens = [ + { + type: TOKEN_TYPE_AUTHOR, + title: TOKEN_TITLE_AUTHOR, + icon: 'pencil', + token: AuthorToken, + dataType: 'user', + unique: true, + defaultAuthors: [], + fetchAuthors: this.fetchUsers, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`, + preloadedAuthors, + }, + { + type: TOKEN_TYPE_ASSIGNEE, + title: TOKEN_TITLE_ASSIGNEE, + icon: 'user', + token: AuthorToken, + dataType: 'user', + unique: !this.hasMultipleIssueAssigneesFeature, + defaultAuthors: DEFAULT_NONE_ANY, + fetchAuthors: this.fetchUsers, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, + preloadedAuthors, + }, + { + type: TOKEN_TYPE_MILESTONE, + title: TOKEN_TITLE_MILESTONE, + icon: 'clock', + token: MilestoneToken, + fetchMilestones: this.fetchMilestones, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`, + }, + { + type: TOKEN_TYPE_LABEL, + title: TOKEN_TITLE_LABEL, + icon: 'labels', + token: LabelToken, + defaultLabels: DEFAULT_NONE_ANY, + fetchLabels: this.fetchLabels, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, + }, + { + type: TOKEN_TYPE_TYPE, + title: TOKEN_TITLE_TYPE, + icon: 'issues', + token: GlFilteredSearchToken, + options: [ + { icon: 'issue-type-issue', title: 'issue', value: 'issue' }, + { icon: 'issue-type-incident', title: 'incident', value: 'incident' }, + { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' }, + ], + }, + ]; + + if (this.isProject) { + tokens.push({ + type: TOKEN_TYPE_RELEASE, + title: TOKEN_TITLE_RELEASE, + icon: 'rocket', + token: ReleaseToken, + fetchReleases: this.fetchReleases, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-release`, + }); + } + + if (this.isSignedIn) { + tokens.push({ + type: TOKEN_TYPE_MY_REACTION, + title: TOKEN_TITLE_MY_REACTION, + icon: 'thumb-up', + token: EmojiToken, + unique: true, + fetchEmojis: this.fetchEmojis, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-my_reaction`, + }); + + tokens.push({ + type: TOKEN_TYPE_CONFIDENTIAL, + title: TOKEN_TITLE_CONFIDENTIAL, + icon: 'eye-slash', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATOR_IS_ONLY, + options: [ + { icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes }, + { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo }, + ], + }); + } + + if (this.eeSearchTokens.length) { + tokens.push(...this.eeSearchTokens); + } + + tokens.sort((a, b) => a.title.localeCompare(b.title)); + + return orderBy(tokens, ['title']); + }, + showPaginationControls() { + return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); + }, + sortOptions() { + return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature); + }, + tabCounts() { + const { openedIssues, closedIssues, allIssues } = this.issuesCounts; + return { + [IssuableStates.Opened]: openedIssues?.count, + [IssuableStates.Closed]: closedIssues?.count, + [IssuableStates.All]: allIssues?.count, + }; + }, + currentTabCount() { + return this.tabCounts[this.state] ?? 0; + }, + urlParams() { + return { + due_date: this.dueDateFilter, + search: this.searchQuery, + sort: urlSortParams[this.sortKey], + state: this.state, + ...this.urlFilterParams, + }; + }, + }, + created() { + this.cache = {}; + }, + mounted() { + eventHub.$on('issuables:toggleBulkEdit', this.toggleBulkEditSidebar); + }, + beforeDestroy() { + eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar); + }, + methods: { + fetchWithCache(path, cacheName, searchKey, search, wrapData = false) { + if (this.cache[cacheName]) { + const data = search + ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey }) + : this.cache[cacheName].slice(0, MAX_LIST_SIZE); + return wrapData ? Promise.resolve({ data }) : Promise.resolve(data); + } + + return axios.get(path).then(({ data }) => { + this.cache[cacheName] = data; + const result = data.slice(0, MAX_LIST_SIZE); + return wrapData ? { data: result } : result; + }); + }, + fetchEmojis(search) { + return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search); + }, + fetchReleases(search) { + return this.fetchWithCache(this.releasesPath, 'releases', 'tag', search); + }, + fetchLabels(search) { + return this.$apollo + .query({ + query: searchLabelsQuery, + variables: { fullPath: this.fullPath, search, isProject: this.isProject }, + }) + .then(({ data }) => data[this.namespace]?.labels.nodes) + .then((labels) => + // TODO remove once we can search by title-only on the backend + // https://gitlab.com/gitlab-org/gitlab/-/issues/346353 + labels.filter((label) => label.title.toLowerCase().includes(search.toLowerCase())), + ); + }, + fetchMilestones(search) { + return this.$apollo + .query({ + query: searchMilestonesQuery, + variables: { fullPath: this.fullPath, search, isProject: this.isProject }, + }) + .then(({ data }) => data[this.namespace]?.milestones.nodes); + }, + fetchUsers(search) { + return this.$apollo + .query({ + query: searchUsersQuery, + variables: { fullPath: this.fullPath, search, isProject: this.isProject }, + }) + .then(({ data }) => + data[this.namespace]?.[`${this.namespace}Members`].nodes.map((member) => member.user), + ); + }, + getExportCsvPathWithQuery() { + return `${this.exportCsvPath}${window.location.search}`; + }, + getStatus(issue) { + if (issue.closedAt && issue.moved) { + return this.$options.i18n.closedMoved; + } + if (issue.closedAt) { + return this.$options.i18n.closed; + } + return undefined; + }, + handleUpdateLegacyBulkEdit() { + // If "select all" checkbox was checked, wait for all checkboxes + // to be checked before updating IssuableBulkUpdateSidebar class + this.$nextTick(() => { + eventHub.$emit('issuables:updateBulkEdit'); + }); + }, + async handleBulkUpdateClick() { + if (!this.hasInitBulkEdit) { + const bulkUpdateSidebar = await import('~/issuable/bulk_update_sidebar'); + bulkUpdateSidebar.initBulkUpdateSidebar('issuable_'); + bulkUpdateSidebar.initIssueStatusSelect(); + + const usersSelect = await import('~/users_select'); + const UsersSelect = usersSelect.default; + new UsersSelect(); // eslint-disable-line no-new + + this.hasInitBulkEdit = true; + } + + eventHub.$emit('issuables:enableBulkEdit'); + }, + handleClickTab(state) { + if (this.state !== state) { + this.pageParams = getInitialPageParams(this.sortKey); + } + this.state = state; + }, + handleDismissAlert() { + this.issuesError = null; + }, + handleFilter(filter) { + if (this.isAnonymousSearchDisabled && !this.isSignedIn) { + this.showAnonymousSearchingMessage(); + return; + } + this.pageParams = getInitialPageParams(this.sortKey); + this.filterTokens = filter; + }, + handleNextPage() { + this.pageParams = { + afterCursor: this.pageInfo.endCursor, + firstPageSize: PAGE_SIZE, + }; + scrollUp(); + }, + handlePreviousPage() { + this.pageParams = { + beforeCursor: this.pageInfo.startCursor, + lastPageSize: PAGE_SIZE, + }; + scrollUp(); + }, + handleReorder({ newIndex, oldIndex }) { + const issueToMove = this.issues[oldIndex]; + const isDragDropDownwards = newIndex > oldIndex; + const isMovingToBeginning = newIndex === 0; + const isMovingToEnd = newIndex === this.issues.length - 1; + + let moveBeforeId; + let moveAfterId; + + if (isDragDropDownwards) { + const afterIndex = isMovingToEnd ? newIndex : newIndex + 1; + moveBeforeId = this.issues[newIndex].id; + moveAfterId = this.issues[afterIndex].id; + } else { + const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1; + moveBeforeId = this.issues[beforeIndex].id; + moveAfterId = this.issues[newIndex].id; + } + + return axios + .put(joinPaths(issueToMove.webPath, 'reorder'), { + move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId), + move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId), + group_full_path: this.isProject ? undefined : this.fullPath, + }) + .then(() => { + const serializedVariables = JSON.stringify(this.queryVariables); + return this.$apollo.mutate({ + mutation: reorderIssuesMutation, + variables: { oldIndex, newIndex, namespace: this.namespace, serializedVariables }, + }); + }) + .catch((error) => { + this.issuesError = this.$options.i18n.reorderError; + Sentry.captureException(error); + }); + }, + handleSort(sortKey) { + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + return; + } + + if (this.sortKey !== sortKey) { + this.pageParams = getInitialPageParams(sortKey); + } + this.sortKey = sortKey; + }, + showAnonymousSearchingMessage() { + createFlash({ + message: this.$options.i18n.anonymousSearchingMessage, + type: FLASH_TYPES.NOTICE, + }); + }, + showIssueRepositioningMessage() { + createFlash({ + message: this.$options.i18n.issueRepositioningMessage, + type: FLASH_TYPES.NOTICE, + }); + }, + toggleBulkEditSidebar(showBulkEditSidebar) { + this.showBulkEditSidebar = showBulkEditSidebar; + }, + }, +}; +</script> + +<template> + <div v-if="hasAnyIssues"> + <issuable-list + :namespace="fullPath" + recent-searches-storage-key="issues" + :search-input-placeholder="$options.i18n.searchPlaceholder" + :search-tokens="searchTokens" + :initial-filter-value="filterTokens" + :sort-options="sortOptions" + :initial-sort-by="sortKey" + :issuables="issues" + :error="issuesError" + label-filter-param="label_name" + :tabs="$options.IssuableListTabs" + :current-tab="state" + :tab-counts="tabCounts" + :issuables-loading="$apollo.queries.issues.loading" + :is-manual-ordering="isManualOrdering" + :show-bulk-edit-sidebar="showBulkEditSidebar" + :show-pagination-controls="showPaginationControls" + :use-keyset-pagination="true" + :has-next-page="pageInfo.hasNextPage" + :has-previous-page="pageInfo.hasPreviousPage" + :url-params="urlParams" + @click-tab="handleClickTab" + @dismiss-alert="handleDismissAlert" + @filter="handleFilter" + @next-page="handleNextPage" + @previous-page="handlePreviousPage" + @reorder="handleReorder" + @sort="handleSort" + @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit" + > + <template #nav-actions> + <gl-button + v-gl-tooltip + :href="rssPath" + icon="rss" + :title="$options.i18n.rssLabel" + :aria-label="$options.i18n.rssLabel" + /> + <gl-button + v-gl-tooltip + :href="calendarPath" + icon="calendar" + :title="$options.i18n.calendarLabel" + :aria-label="$options.i18n.calendarLabel" + /> + <csv-import-export-buttons + v-if="showCsvButtons" + class="gl-md-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="currentTabCount" + /> + <gl-button + v-if="canBulkUpdate" + :disabled="isBulkEditButtonDisabled" + @click="handleBulkUpdateClick" + > + {{ $options.i18n.editIssues }} + </gl-button> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + <new-issue-dropdown v-if="showNewIssueDropdown" /> + </template> + + <template #timeframe="{ issuable = {} }"> + <issue-card-time-info :issue="issuable" /> + </template> + + <template #status="{ issuable = {} }"> + {{ getStatus(issuable) }} + </template> + + <template #statistics="{ issuable = {} }"> + <li + v-if="issuable.mergeRequestsCount" + v-gl-tooltip + class="gl-display-none gl-sm-display-block" + :title="$options.i18n.relatedMergeRequests" + data-testid="merge-requests" + > + <gl-icon name="merge-request" /> + {{ issuable.mergeRequestsCount }} + </li> + <li + v-if="issuable.upvotes" + v-gl-tooltip + class="issuable-upvotes gl-display-none gl-sm-display-block" + :title="$options.i18n.upvotes" + data-testid="issuable-upvotes" + > + <gl-icon name="thumb-up" /> + {{ issuable.upvotes }} + </li> + <li + v-if="issuable.downvotes" + v-gl-tooltip + class="issuable-downvotes gl-display-none gl-sm-display-block" + :title="$options.i18n.downvotes" + data-testid="issuable-downvotes" + > + <gl-icon name="thumb-down" /> + {{ issuable.downvotes }} + </li> + <slot :issuable="issuable"></slot> + </template> + + <template #empty-state> + <gl-empty-state + v-if="hasSearch" + :description="$options.i18n.noSearchResultsDescription" + :title="$options.i18n.noSearchResultsTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + </gl-empty-state> + + <gl-empty-state + v-else-if="isOpenTab" + :description="$options.i18n.noOpenIssuesDescription" + :title="$options.i18n.noOpenIssuesTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + </gl-empty-state> + + <gl-empty-state + v-else + :title="$options.i18n.noClosedIssuesTitle" + :svg-path="emptyStateSvgPath" + /> + </template> + </issuable-list> + + <issuable-by-email v-if="initialEmail" class="gl-text-center gl-pt-5 gl-pb-7" /> + </div> + + <div v-else-if="isSignedIn"> + <gl-empty-state + :description="$options.i18n.noIssuesSignedInDescription" + :title="$options.i18n.noIssuesSignedInTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + <csv-import-export-buttons + v-if="showCsvButtons" + class="gl-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="currentTabCount" + /> + <new-issue-dropdown v-if="showNewIssueDropdown" /> + </template> + </gl-empty-state> + <hr /> + <p class="gl-text-center gl-font-weight-bold gl-mb-0"> + {{ $options.i18n.jiraIntegrationTitle }} + </p> + <p class="gl-text-center gl-mb-0"> + <gl-sprintf :message="$options.i18n.jiraIntegrationMessage"> + <template #jiraDocsLink="{ content }"> + <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p class="gl-text-center gl-text-gray-500"> + {{ $options.i18n.jiraIntegrationSecondaryMessage }} + </p> + </div> + + <gl-empty-state + v-else + :description="$options.i18n.noIssuesSignedOutDescription" + :title="$options.i18n.noIssuesSignedOutTitle" + :svg-path="emptyStateSvgPath" + :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" + :primary-button-link="signInPath" + /> +</template> diff --git a/app/assets/javascripts/issues/list/components/jira_issues_import_status_app.vue b/app/assets/javascripts/issues/list/components/jira_issues_import_status_app.vue new file mode 100644 index 00000000000..fb1dbef666c --- /dev/null +++ b/app/assets/javascripts/issues/list/components/jira_issues_import_status_app.vue @@ -0,0 +1,112 @@ +<script> +import { GlAlert, GlLabel } from '@gitlab/ui'; +import { last } from 'lodash'; +import { + calculateJiraImportLabel, + isInProgress, + setFinishedAlertHideMap, + shouldShowFinishedAlert, +} from '~/jira_import/utils/jira_import_utils'; +import { n__ } from '~/locale'; +import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql'; + +export default { + name: 'JiraIssuesImportStatus', + components: { + GlAlert, + GlLabel, + }, + props: { + canEdit: { + type: Boolean, + required: true, + }, + isJiraConfigured: { + type: Boolean, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + jiraImport: {}, + }; + }, + apollo: { + jiraImport: { + query: getIssuesListDetailsQuery, + variables() { + return { + fullPath: this.projectPath, + }; + }, + update: ({ project }) => { + const label = calculateJiraImportLabel( + project.jiraImports.nodes, + project.issues.nodes.flatMap(({ labels }) => labels.nodes), + ); + return { + importedIssuesCount: last(project.jiraImports.nodes)?.importedIssuesCount, + label, + shouldShowFinishedAlert: shouldShowFinishedAlert(label.title, project.jiraImportStatus), + shouldShowInProgressAlert: isInProgress(project.jiraImportStatus), + }; + }, + }, + }, + computed: { + finishedMessage() { + return n__( + '%d issue successfully imported with the label', + '%d issues successfully imported with the label', + this.jiraImport.importedIssuesCount, + ); + }, + labelTarget() { + return `${this.issuesPath}?label_name[]=${encodeURIComponent(this.jiraImport.label.title)}`; + }, + shouldRender() { + return this.jiraImport.shouldShowInProgressAlert || this.jiraImport.shouldShowFinishedAlert; + }, + }, + methods: { + hideFinishedAlert() { + setFinishedAlertHideMap(this.jiraImport.label.title); + this.jiraImport.shouldShowFinishedAlert = false; + }, + hideInProgressAlert() { + this.jiraImport.shouldShowInProgressAlert = false; + }, + }, +}; +</script> + +<template> + <div v-if="shouldRender" class="gl-my-5"> + <gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert"> + {{ __('Import in progress. Refresh page to see newly added issues.') }} + </gl-alert> + + <gl-alert + v-else-if="jiraImport.shouldShowFinishedAlert" + variant="success" + @dismiss="hideFinishedAlert" + > + {{ finishedMessage }} + <gl-label + :background-color="jiraImport.label.color" + scoped + size="sm" + :target="labelTarget" + :title="jiraImport.label.title" + /> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue new file mode 100644 index 00000000000..71f84050ba8 --- /dev/null +++ b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue @@ -0,0 +1,127 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import createFlash from '~/flash'; +import searchProjectsQuery from '~/issues/list/queries/search_projects.query.graphql'; +import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; +import { __, sprintf } from '~/locale'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; + +export default { + i18n: { + defaultDropdownText: __('Select project to create issue'), + noMatchesFound: __('No matches found'), + toggleButtonLabel: __('Toggle project select'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLoadingIcon, + GlSearchBoxByType, + }, + inject: ['fullPath'], + data() { + return { + projects: [], + search: '', + selectedProject: {}, + shouldSkipQuery: true, + }; + }, + apollo: { + projects: { + query: searchProjectsQuery, + variables() { + return { + fullPath: this.fullPath, + search: this.search, + }; + }, + update: ({ group }) => group.projects.nodes ?? [], + error(error) { + createFlash({ + message: __('An error occurred while loading projects.'), + captureError: true, + error, + }); + }, + skip() { + return this.shouldSkipQuery; + }, + debounce: DEBOUNCE_DELAY, + }, + }, + computed: { + dropdownHref() { + return this.hasSelectedProject + ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, 'issues/new') + : undefined; + }, + dropdownText() { + return this.hasSelectedProject + ? sprintf(__('New issue in %{project}'), { project: this.selectedProject.name }) + : this.$options.i18n.defaultDropdownText; + }, + hasSelectedProject() { + return this.selectedProject.id; + }, + projectsWithIssuesEnabled() { + return this.projects.filter((project) => project.issuesEnabled); + }, + showNoSearchResultsText() { + return !this.projectsWithIssuesEnabled.length && this.search; + }, + }, + methods: { + handleDropdownClick() { + if (!this.dropdownHref) { + this.$refs.dropdown.show(); + } + }, + handleDropdownShown() { + if (this.shouldSkipQuery) { + this.shouldSkipQuery = false; + } + this.$refs.search.focusInput(); + }, + selectProject(project) { + this.selectedProject = project; + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="dropdown" + right + split + :split-href="dropdownHref" + :text="dropdownText" + :toggle-text="$options.i18n.toggleButtonLabel" + variant="confirm" + @click="handleDropdownClick" + @shown="handleDropdownShown" + > + <gl-search-box-by-type ref="search" v-model.trim="search" /> + <gl-loading-icon v-if="$apollo.queries.projects.loading" /> + <template v-else> + <gl-dropdown-item + v-for="project of projectsWithIssuesEnabled" + :key="project.id" + @click="selectProject(project)" + > + {{ project.nameWithNamespace }} + </gl-dropdown-item> + <gl-dropdown-text v-if="showNoSearchResultsText"> + {{ $options.i18n.noMatchesFound }} + </gl-dropdown-text> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js new file mode 100644 index 00000000000..4a380848b4f --- /dev/null +++ b/app/assets/javascripts/issues/list/constants.js @@ -0,0 +1,316 @@ +import { __, s__ } from '~/locale'; +import { + FILTER_ANY, + FILTER_CURRENT, + FILTER_NONE, + FILTER_STARTED, + FILTER_UPCOMING, + OPERATOR_IS, + OPERATOR_IS_NOT, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +export const i18n = { + anonymousSearchingMessage: __('You must sign in to search for specific terms.'), + calendarLabel: __('Subscribe to calendar'), + closed: __('CLOSED'), + closedMoved: __('CLOSED (MOVED)'), + confidentialNo: __('No'), + confidentialYes: __('Yes'), + downvotes: __('Downvotes'), + editIssues: __('Edit issues'), + errorFetchingCounts: __('An error occurred while getting issue counts'), + errorFetchingIssues: __('An error occurred while loading issues'), + issueRepositioningMessage: __( + 'Issues are being rebalanced at the moment, so manual reordering is disabled.', + ), + jiraIntegrationMessage: s__( + 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.', + ), + jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'), + jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'), + newIssueLabel: __('New issue'), + noClosedIssuesTitle: __('There are no closed issues'), + noOpenIssuesDescription: __('To keep this project going, create a new issue'), + noOpenIssuesTitle: __('There are no open issues'), + noIssuesSignedInDescription: __( + 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.', + ), + noIssuesSignedInTitle: __( + 'The Issue Tracker is the place to add things that need to be improved or solved in a project', + ), + noIssuesSignedOutButtonText: __('Register / Sign In'), + noIssuesSignedOutDescription: __( + 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', + ), + noIssuesSignedOutTitle: __('There are no issues to show'), + noSearchResultsDescription: __('To widen your search, change or remove filters above'), + noSearchResultsTitle: __('Sorry, your filter produced no results'), + relatedMergeRequests: __('Related merge requests'), + reorderError: __('An error occurred while reordering issues.'), + rssLabel: __('Subscribe to RSS feed'), + searchPlaceholder: __('Search or filter results...'), + upvotes: __('Upvotes'), +}; + +export const MAX_LIST_SIZE = 10; +export const PAGE_SIZE = 20; +export const PAGE_SIZE_MANUAL = 100; +export const PARAM_DUE_DATE = 'due_date'; +export const PARAM_SORT = 'sort'; +export const PARAM_STATE = 'state'; +export const RELATIVE_POSITION = 'relative_position'; + +export const defaultPageSizeParams = { + firstPageSize: PAGE_SIZE, +}; + +export const largePageSizeParams = { + firstPageSize: PAGE_SIZE_MANUAL, +}; + +export const DUE_DATE_NONE = '0'; +export const DUE_DATE_ANY = ''; +export const DUE_DATE_OVERDUE = 'overdue'; +export const DUE_DATE_WEEK = 'week'; +export const DUE_DATE_MONTH = 'month'; +export const DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS = 'next_month_and_previous_two_weeks'; +export const DUE_DATE_VALUES = [ + DUE_DATE_NONE, + DUE_DATE_ANY, + DUE_DATE_OVERDUE, + DUE_DATE_WEEK, + DUE_DATE_MONTH, + DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS, +]; + +export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; +export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; +export const CREATED_ASC = 'CREATED_ASC'; +export const CREATED_DESC = 'CREATED_DESC'; +export const DUE_DATE_ASC = 'DUE_DATE_ASC'; +export const DUE_DATE_DESC = 'DUE_DATE_DESC'; +export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC'; +export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC'; +export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC'; +export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC'; +export const POPULARITY_ASC = 'POPULARITY_ASC'; +export const POPULARITY_DESC = 'POPULARITY_DESC'; +export const PRIORITY_ASC = 'PRIORITY_ASC'; +export const PRIORITY_DESC = 'PRIORITY_DESC'; +export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC'; +export const TITLE_ASC = 'TITLE_ASC'; +export const TITLE_DESC = 'TITLE_DESC'; +export const UPDATED_ASC = 'UPDATED_ASC'; +export const UPDATED_DESC = 'UPDATED_DESC'; +export const WEIGHT_ASC = 'WEIGHT_ASC'; +export const WEIGHT_DESC = 'WEIGHT_DESC'; + +export const urlSortParams = { + [PRIORITY_ASC]: 'priority', + [PRIORITY_DESC]: 'priority_desc', + [CREATED_ASC]: 'created_asc', + [CREATED_DESC]: 'created_date', + [UPDATED_ASC]: 'updated_asc', + [UPDATED_DESC]: 'updated_desc', + [MILESTONE_DUE_ASC]: 'milestone', + [MILESTONE_DUE_DESC]: 'milestone_due_desc', + [DUE_DATE_ASC]: 'due_date', + [DUE_DATE_DESC]: 'due_date_desc', + [POPULARITY_ASC]: 'popularity_asc', + [POPULARITY_DESC]: 'popularity', + [LABEL_PRIORITY_ASC]: 'label_priority', + [LABEL_PRIORITY_DESC]: 'label_priority_desc', + [RELATIVE_POSITION_ASC]: RELATIVE_POSITION, + [WEIGHT_ASC]: 'weight', + [WEIGHT_DESC]: 'weight_desc', + [BLOCKING_ISSUES_ASC]: 'blocking_issues_asc', + [BLOCKING_ISSUES_DESC]: 'blocking_issues_desc', + [TITLE_ASC]: 'title_asc', + [TITLE_DESC]: 'title_desc', +}; + +export const API_PARAM = 'apiParam'; +export const URL_PARAM = 'urlParam'; +export const NORMAL_FILTER = 'normalFilter'; +export const SPECIAL_FILTER = 'specialFilter'; +export const ALTERNATIVE_FILTER = 'alternativeFilter'; +export const SPECIAL_FILTER_VALUES = [ + FILTER_NONE, + FILTER_ANY, + FILTER_CURRENT, + FILTER_UPCOMING, + FILTER_STARTED, +]; + +export const TOKEN_TYPE_AUTHOR = 'author_username'; +export const TOKEN_TYPE_ASSIGNEE = 'assignee_username'; +export const TOKEN_TYPE_MILESTONE = 'milestone'; +export const TOKEN_TYPE_LABEL = 'labels'; +export const TOKEN_TYPE_TYPE = 'type'; +export const TOKEN_TYPE_RELEASE = 'release'; +export const TOKEN_TYPE_MY_REACTION = 'my_reaction_emoji'; +export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; +export const TOKEN_TYPE_ITERATION = 'iteration'; +export const TOKEN_TYPE_EPIC = 'epic_id'; +export const TOKEN_TYPE_WEIGHT = 'weight'; + +export const filters = { + [TOKEN_TYPE_AUTHOR]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'authorUsername', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'author_username', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[author_username]', + }, + }, + }, + [TOKEN_TYPE_ASSIGNEE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'assigneeUsernames', + [SPECIAL_FILTER]: 'assigneeId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'assignee_username[]', + [SPECIAL_FILTER]: 'assignee_id', + [ALTERNATIVE_FILTER]: 'assignee_username', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[assignee_username][]', + }, + }, + }, + [TOKEN_TYPE_MILESTONE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'milestoneTitle', + [SPECIAL_FILTER]: 'milestoneWildcardId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'milestone_title', + [SPECIAL_FILTER]: 'milestone_title', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[milestone_title]', + }, + }, + }, + [TOKEN_TYPE_LABEL]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'labelName', + [SPECIAL_FILTER]: 'labelName', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'label_name[]', + [SPECIAL_FILTER]: 'label_name[]', + [ALTERNATIVE_FILTER]: 'label_name', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[label_name][]', + }, + }, + }, + [TOKEN_TYPE_TYPE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'types', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'type[]', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[type][]', + }, + }, + }, + [TOKEN_TYPE_RELEASE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'releaseTag', + [SPECIAL_FILTER]: 'releaseTagWildcardId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'release_tag', + [SPECIAL_FILTER]: 'release_tag', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[release_tag]', + }, + }, + }, + [TOKEN_TYPE_MY_REACTION]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'myReactionEmoji', + [SPECIAL_FILTER]: 'myReactionEmoji', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'my_reaction_emoji', + [SPECIAL_FILTER]: 'my_reaction_emoji', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[my_reaction_emoji]', + }, + }, + }, + [TOKEN_TYPE_CONFIDENTIAL]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'confidential', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'confidential', + }, + }, + }, + [TOKEN_TYPE_ITERATION]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'iterationId', + [SPECIAL_FILTER]: 'iterationWildcardId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'iteration_id', + [SPECIAL_FILTER]: 'iteration_id', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[iteration_id]', + }, + }, + }, + [TOKEN_TYPE_EPIC]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'epicId', + [SPECIAL_FILTER]: 'epicId', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'epic_id', + [SPECIAL_FILTER]: 'epic_id', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[epic_id]', + }, + }, + }, + [TOKEN_TYPE_WEIGHT]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'weight', + [SPECIAL_FILTER]: 'weight', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'weight', + [SPECIAL_FILTER]: 'weight', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[weight]', + }, + }, + }, +}; diff --git a/app/assets/javascripts/issues/list/eventhub.js b/app/assets/javascripts/issues/list/eventhub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/issues/list/eventhub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js new file mode 100644 index 00000000000..01cc82ed8fd --- /dev/null +++ b/app/assets/javascripts/issues/list/index.js @@ -0,0 +1,165 @@ +import produce from 'immer'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +import IssuesListApp from 'ee_else_ce/issues/list/components/issues_list_app.vue'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue'; + +export function mountJiraIssuesListApp() { + const el = document.querySelector('.js-jira-issues-import-status'); + + if (!el) { + return false; + } + + const { issuesPath, projectPath } = el.dataset; + const canEdit = parseBoolean(el.dataset.canEdit); + const isJiraConfigured = parseBoolean(el.dataset.isJiraConfigured); + + if (!isJiraConfigured || !canEdit) { + return false; + } + + Vue.use(VueApollo); + const defaultClient = createDefaultClient(); + const apolloProvider = new VueApollo({ + defaultClient, + }); + + return new Vue({ + el, + apolloProvider, + render(createComponent) { + return createComponent(JiraIssuesImportStatusRoot, { + props: { + canEdit, + isJiraConfigured, + issuesPath, + projectPath, + }, + }); + }, + }); +} + +export function mountIssuesListApp() { + const el = document.querySelector('.js-issues-list'); + + if (!el) { + return false; + } + + Vue.use(VueApollo); + + const resolvers = { + Mutation: { + reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => { + const variables = JSON.parse(serializedVariables); + const sourceData = cache.readQuery({ query: getIssuesQuery, variables }); + + const data = produce(sourceData, (draftData) => { + const issues = draftData[namespace].issues.nodes.slice(); + const issueToMove = issues[oldIndex]; + issues.splice(oldIndex, 1); + issues.splice(newIndex, 0, issueToMove); + + draftData[namespace].issues.nodes = issues; + }); + + cache.writeQuery({ query: getIssuesQuery, variables, data }); + }, + }, + }; + + const defaultClient = createDefaultClient(resolvers); + const apolloProvider = new VueApollo({ + defaultClient, + }); + + const { + autocompleteAwardEmojisPath, + calendarPath, + canBulkUpdate, + canEdit, + canImportIssues, + email, + emailsHelpPagePath, + emptyStateSvgPath, + exportCsvPath, + fullPath, + groupPath, + hasAnyIssues, + hasAnyProjects, + hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature, + hasIssueWeightsFeature, + hasIterationsFeature, + hasMultipleIssueAssigneesFeature, + importCsvIssuesPath, + initialEmail, + isAnonymousSearchDisabled, + isIssueRepositioningDisabled, + isProject, + isSignedIn, + jiraIntegrationPath, + markdownHelpPath, + maxAttachmentSize, + newIssuePath, + projectImportJiraPath, + quickActionsHelpPath, + releasesPath, + resetPath, + rssPath, + showNewIssueLink, + signInPath, + } = el.dataset; + + return new Vue({ + el, + apolloProvider, + provide: { + autocompleteAwardEmojisPath, + calendarPath, + canBulkUpdate: parseBoolean(canBulkUpdate), + emptyStateSvgPath, + fullPath, + groupPath, + hasAnyIssues: parseBoolean(hasAnyIssues), + hasAnyProjects: parseBoolean(hasAnyProjects), + hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), + hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), + hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + hasIterationsFeature: parseBoolean(hasIterationsFeature), + hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature), + isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), + isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), + isProject: parseBoolean(isProject), + isSignedIn: parseBoolean(isSignedIn), + jiraIntegrationPath, + newIssuePath, + releasesPath, + rssPath, + showNewIssueLink: parseBoolean(showNewIssueLink), + signInPath, + // For CsvImportExportButtons component + canEdit: parseBoolean(canEdit), + email, + exportCsvPath, + importCsvIssuesPath, + maxAttachmentSize, + projectImportJiraPath, + showExportButton: parseBoolean(hasAnyIssues), + showImportButton: parseBoolean(canImportIssues), + showLabel: !parseBoolean(hasAnyIssues), + // For IssuableByEmail component + emailsHelpPagePath, + initialEmail, + markdownHelpPath, + quickActionsHelpPath, + resetPath, + }, + render: (createComponent) => createComponent(IssuesListApp), + }); +} diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql new file mode 100644 index 00000000000..be8deb3fe97 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -0,0 +1,90 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "./issue.fragment.graphql" + +query getIssues( + $isProject: Boolean = false + $isSignedIn: Boolean = false + $fullPath: ID! + $search: String + $sort: IssueSort + $state: IssuableState + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $confidential: Boolean + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $releaseTag: [String!] + $releaseTagWildcardId: ReleaseTagWildcardId + $types: [IssueType!] + $not: NegatedIssueFilterInput + $beforeCursor: String + $afterCursor: String + $firstPageSize: Int + $lastPageSize: Int +) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + issues( + includeSubgroups: true + search: $search + sort: $sort + state: $state + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + not: $not + before: $beforeCursor + after: $afterCursor + first: $firstPageSize + last: $lastPageSize + ) { + pageInfo { + ...PageInfo + } + nodes { + ...IssueFragment + reference(full: true) + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + issues( + search: $search + sort: $sort + state: $state + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + before: $beforeCursor + after: $afterCursor + first: $firstPageSize + last: $lastPageSize + ) { + pageInfo { + ...PageInfo + } + nodes { + ...IssueFragment + } + } + } +} diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql new file mode 100644 index 00000000000..1a345fd2877 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql @@ -0,0 +1,129 @@ +query getIssuesCount( + $isProject: Boolean = false + $fullPath: ID! + $search: String + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $confidential: Boolean + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $releaseTag: [String!] + $releaseTagWildcardId: ReleaseTagWildcardId + $types: [IssueType!] + $not: NegatedIssueFilterInput +) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + openedIssues: issues( + includeSubgroups: true + state: opened + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + not: $not + ) { + count + } + closedIssues: issues( + includeSubgroups: true + state: closed + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + not: $not + ) { + count + } + allIssues: issues( + includeSubgroups: true + state: all + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + not: $not + ) { + count + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + openedIssues: issues( + state: opened + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + ) { + count + } + closedIssues: issues( + state: closed + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + ) { + count + } + allIssues: issues( + state: all + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + not: $not + ) { + count + } + } +} diff --git a/app/assets/javascripts/issues/list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_list_details.query.graphql new file mode 100644 index 00000000000..a53dba8c7c8 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/get_issues_list_details.query.graphql @@ -0,0 +1,24 @@ +query getIssuesListDetails($fullPath: ID!) { + project(fullPath: $fullPath) { + id + issues { + nodes { + id + labels { + nodes { + id + title + color + } + } + } + } + jiraImportStatus + jiraImports { + nodes { + importedIssuesCount + jiraProjectKey + } + } + } +} diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql new file mode 100644 index 00000000000..07dae3fd756 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -0,0 +1,54 @@ +fragment IssueFragment on Issue { + id + iid + closedAt + confidential + createdAt + downvotes + dueDate + hidden + humanTimeEstimate + mergeRequestsCount + moved + title + updatedAt + upvotes + userDiscussionsCount @include(if: $isSignedIn) + webPath + webUrl + assignees { + nodes { + id + avatarUrl + name + username + webUrl + } + } + author { + id + avatarUrl + name + username + webUrl + } + labels { + nodes { + id + color + title + description + } + } + milestone { + id + dueDate + startDate + webPath + title + } + taskCompletionStatus { + completedCount + count + } +} diff --git a/app/assets/javascripts/issues/list/queries/label.fragment.graphql b/app/assets/javascripts/issues/list/queries/label.fragment.graphql new file mode 100644 index 00000000000..bb1d8f1ac9b --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/label.fragment.graphql @@ -0,0 +1,6 @@ +fragment Label on Label { + id + color + textColor + title +} diff --git a/app/assets/javascripts/issues/list/queries/milestone.fragment.graphql b/app/assets/javascripts/issues/list/queries/milestone.fragment.graphql new file mode 100644 index 00000000000..3cdf69bf585 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/milestone.fragment.graphql @@ -0,0 +1,4 @@ +fragment Milestone on Milestone { + id + title +} diff --git a/app/assets/javascripts/issues/list/queries/reorder_issues.mutation.graphql b/app/assets/javascripts/issues/list/queries/reorder_issues.mutation.graphql new file mode 100644 index 00000000000..160026a4742 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/reorder_issues.mutation.graphql @@ -0,0 +1,13 @@ +mutation reorderIssues( + $oldIndex: Int + $newIndex: Int + $namespace: String + $serializedVariables: String +) { + reorderIssues( + oldIndex: $oldIndex + newIndex: $newIndex + namespace: $namespace + serializedVariables: $serializedVariables + ) @client +} diff --git a/app/assets/javascripts/issues/list/queries/search_labels.query.graphql b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql new file mode 100644 index 00000000000..44b57317161 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql @@ -0,0 +1,20 @@ +#import "./label.fragment.graphql" + +query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + labels(searchTerm: $search, includeAncestorGroups: true, includeDescendantGroups: true) { + nodes { + ...Label + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + labels(searchTerm: $search, includeAncestorGroups: true) { + nodes { + ...Label + } + } + } +} diff --git a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql new file mode 100644 index 00000000000..e7eb08104a6 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql @@ -0,0 +1,20 @@ +#import "./milestone.fragment.graphql" + +query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + milestones(searchTitle: $search, includeAncestors: true, includeDescendants: true) { + nodes { + ...Milestone + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + milestones(searchTitle: $search, includeAncestors: true) { + nodes { + ...Milestone + } + } + } +} diff --git a/app/assets/javascripts/issues/list/queries/search_projects.query.graphql b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql new file mode 100644 index 00000000000..bd2f9bc2340 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql @@ -0,0 +1,14 @@ +query searchProjects($fullPath: ID!, $search: String) { + group(fullPath: $fullPath) { + id + projects(search: $search, includeSubgroups: true) { + nodes { + id + issuesEnabled + name + nameWithNamespace + webUrl + } + } + } +} diff --git a/app/assets/javascripts/issues/list/queries/search_users.query.graphql b/app/assets/javascripts/issues/list/queries/search_users.query.graphql new file mode 100644 index 00000000000..92517ad35d0 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/search_users.query.graphql @@ -0,0 +1,26 @@ +#import "./user.fragment.graphql" + +query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + groupMembers(search: $search) { + nodes { + id + user { + ...User + } + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + projectMembers(search: $search) { + nodes { + id + user { + ...User + } + } + } + } +} diff --git a/app/assets/javascripts/issues/list/queries/user.fragment.graphql b/app/assets/javascripts/issues/list/queries/user.fragment.graphql new file mode 100644 index 00000000000..3e5bc0f7b93 --- /dev/null +++ b/app/assets/javascripts/issues/list/queries/user.fragment.graphql @@ -0,0 +1,6 @@ +fragment User on User { + id + avatarUrl + name + username +} diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js new file mode 100644 index 00000000000..2919bbbfef8 --- /dev/null +++ b/app/assets/javascripts/issues/list/utils.js @@ -0,0 +1,261 @@ +import { + API_PARAM, + BLOCKING_ISSUES_ASC, + BLOCKING_ISSUES_DESC, + CREATED_ASC, + CREATED_DESC, + defaultPageSizeParams, + DUE_DATE_ASC, + DUE_DATE_DESC, + DUE_DATE_VALUES, + filters, + LABEL_PRIORITY_ASC, + LABEL_PRIORITY_DESC, + largePageSizeParams, + MILESTONE_DUE_ASC, + MILESTONE_DUE_DESC, + NORMAL_FILTER, + POPULARITY_ASC, + POPULARITY_DESC, + PRIORITY_ASC, + PRIORITY_DESC, + RELATIVE_POSITION_ASC, + SPECIAL_FILTER, + SPECIAL_FILTER_VALUES, + TITLE_ASC, + TITLE_DESC, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_ITERATION, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_TYPE, + UPDATED_ASC, + UPDATED_DESC, + URL_PARAM, + urlSortParams, + WEIGHT_ASC, + WEIGHT_DESC, +} from '~/issues/list/constants'; +import { isPositiveInteger } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +import { + FILTERED_SEARCH_TERM, + OPERATOR_IS_NOT, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +export const getInitialPageParams = (sortKey) => + sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams; + +export const getSortKey = (sort) => + Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort); + +export const getDueDateValue = (value) => (DUE_DATE_VALUES.includes(value) ? value : undefined); + +export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) => { + const sortOptions = [ + { + id: 1, + title: __('Priority'), + sortDirection: { + ascending: PRIORITY_ASC, + descending: PRIORITY_DESC, + }, + }, + { + id: 2, + title: __('Created date'), + sortDirection: { + ascending: CREATED_ASC, + descending: CREATED_DESC, + }, + }, + { + id: 3, + title: __('Updated date'), + sortDirection: { + ascending: UPDATED_ASC, + descending: UPDATED_DESC, + }, + }, + { + id: 4, + title: __('Milestone due date'), + sortDirection: { + ascending: MILESTONE_DUE_ASC, + descending: MILESTONE_DUE_DESC, + }, + }, + { + id: 5, + title: __('Due date'), + sortDirection: { + ascending: DUE_DATE_ASC, + descending: DUE_DATE_DESC, + }, + }, + { + id: 6, + title: __('Popularity'), + sortDirection: { + ascending: POPULARITY_ASC, + descending: POPULARITY_DESC, + }, + }, + { + id: 7, + title: __('Label priority'), + sortDirection: { + ascending: LABEL_PRIORITY_ASC, + descending: LABEL_PRIORITY_DESC, + }, + }, + { + id: 8, + title: __('Manual'), + sortDirection: { + ascending: RELATIVE_POSITION_ASC, + descending: RELATIVE_POSITION_ASC, + }, + }, + { + id: 9, + title: __('Title'), + sortDirection: { + ascending: TITLE_ASC, + descending: TITLE_DESC, + }, + }, + ]; + + if (hasIssueWeightsFeature) { + sortOptions.push({ + id: sortOptions.length + 1, + title: __('Weight'), + sortDirection: { + ascending: WEIGHT_ASC, + descending: WEIGHT_DESC, + }, + }); + } + + if (hasBlockedIssuesFeature) { + sortOptions.push({ + id: sortOptions.length + 1, + title: __('Blocking'), + sortDirection: { + ascending: BLOCKING_ISSUES_ASC, + descending: BLOCKING_ISSUES_DESC, + }, + }); + } + + return sortOptions; +}; + +const tokenTypes = Object.keys(filters); + +const getUrlParams = (tokenType) => + Object.values(filters[tokenType][URL_PARAM]).flatMap((filterObj) => Object.values(filterObj)); + +const urlParamKeys = tokenTypes.flatMap(getUrlParams); + +const getTokenTypeFromUrlParamKey = (urlParamKey) => + tokenTypes.find((tokenType) => getUrlParams(tokenType).includes(urlParamKey)); + +const getOperatorFromUrlParamKey = (tokenType, urlParamKey) => + Object.entries(filters[tokenType][URL_PARAM]).find(([, filterObj]) => + Object.values(filterObj).includes(urlParamKey), + )[0]; + +const convertToFilteredTokens = (locationSearch) => + Array.from(new URLSearchParams(locationSearch).entries()) + .filter(([key]) => urlParamKeys.includes(key)) + .map(([key, data]) => { + const type = getTokenTypeFromUrlParamKey(key); + const operator = getOperatorFromUrlParamKey(type, key); + return { + type, + value: { data, operator }, + }; + }); + +const convertToFilteredSearchTerms = (locationSearch) => + new URLSearchParams(locationSearch) + .get('search') + ?.split(' ') + .map((word) => ({ + type: FILTERED_SEARCH_TERM, + value: { + data: word, + }, + })) || []; + +export const getFilterTokens = (locationSearch) => { + if (!locationSearch) { + return []; + } + const filterTokens = convertToFilteredTokens(locationSearch); + const searchTokens = convertToFilteredSearchTerms(locationSearch); + return filterTokens.concat(searchTokens); +}; + +const getFilterType = (data, tokenType = '') => + SPECIAL_FILTER_VALUES.includes(data) || + (tokenType === TOKEN_TYPE_ASSIGNEE && isPositiveInteger(data)) + ? SPECIAL_FILTER + : NORMAL_FILTER; + +const wildcardTokens = [TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_RELEASE]; + +const isWildcardValue = (tokenType, value) => + wildcardTokens.includes(tokenType) && SPECIAL_FILTER_VALUES.includes(value); + +const requiresUpperCaseValue = (tokenType, value) => + tokenType === TOKEN_TYPE_TYPE || isWildcardValue(tokenType, value); + +const formatData = (token) => { + if (requiresUpperCaseValue(token.type, token.value.data)) { + return token.value.data.toUpperCase(); + } + if (token.type === TOKEN_TYPE_CONFIDENTIAL) { + return token.value.data === 'yes'; + } + return token.value.data; +}; + +export const convertToApiParams = (filterTokens) => { + const params = {}; + const not = {}; + + filterTokens + .filter((token) => token.type !== FILTERED_SEARCH_TERM) + .forEach((token) => { + const filterType = getFilterType(token.value.data, token.type); + const field = filters[token.type][API_PARAM][filterType]; + const obj = token.value.operator === OPERATOR_IS_NOT ? not : params; + const data = formatData(token); + Object.assign(obj, { + [field]: obj[field] ? [obj[field], data].flat() : data, + }); + }); + + return Object.keys(not).length ? Object.assign(params, { not }) : params; +}; + +export const convertToUrlParams = (filterTokens) => + filterTokens + .filter((token) => token.type !== FILTERED_SEARCH_TERM) + .reduce((acc, token) => { + const filterType = getFilterType(token.value.data, token.type); + const param = filters[token.type][URL_PARAM][token.value.operator]?.[filterType]; + return Object.assign(acc, { + [param]: acc[param] ? [acc[param], token.value.data].flat() : token.value.data, + }); + }, {}); + +export const convertToSearchQuery = (filterTokens) => + filterTokens + .filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data) + .map((token) => token.value.data) + .join(' '); diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js index 9613246d6a6..c78505d0610 100644 --- a/app/assets/javascripts/issues/manual_ordering.js +++ b/app/assets/javascripts/issues/manual_ordering.js @@ -20,7 +20,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) => }); }); -const initManualOrdering = (draggableSelector = 'li.issue') => { +const initManualOrdering = () => { const issueList = document.querySelector('.manual-ordering'); if (!issueList || !(gon.current_user_id > 0)) { @@ -37,14 +37,14 @@ const initManualOrdering = (draggableSelector = 'li.issue') => { group: { name: 'issues', }, - draggable: draggableSelector, + draggable: 'li.issue', onStart: () => { sortableStart(); }, onUpdate: (event) => { const el = event.item; - const url = el.getAttribute('url') || el.dataset.url; + const url = el.getAttribute('url'); const prev = el.previousElementSibling; const next = el.nextElementSibling; diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js index 59a7cbec627..f96cacf2595 100644 --- a/app/assets/javascripts/issues/new/index.js +++ b/app/assets/javascripts/issues/new/index.js @@ -5,8 +5,6 @@ import TitleSuggestions from './components/title_suggestions.vue'; import TypePopover from './components/type_popover.vue'; export function initTitleSuggestions() { - Vue.use(VueApollo); - const el = document.getElementById('js-suggestions'); const issueTitle = document.getElementById('issue_title'); @@ -14,6 +12,8 @@ export function initTitleSuggestions() { return undefined; } + Vue.use(VueApollo); + const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); diff --git a/app/assets/javascripts/issues/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js index ce33cf7df1d..5045f7e1a2a 100644 --- a/app/assets/javascripts/issues/related_merge_requests/index.js +++ b/app/assets/javascripts/issues/related_merge_requests/index.js @@ -2,23 +2,21 @@ import Vue from 'vue'; import RelatedMergeRequests from './components/related_merge_requests.vue'; import createStore from './store'; -export default function initRelatedMergeRequests() { - const relatedMergeRequestsElement = document.querySelector('#js-related-merge-requests'); +export function initRelatedMergeRequests() { + const el = document.querySelector('#js-related-merge-requests'); - if (relatedMergeRequestsElement) { - const { endpoint, projectPath, projectNamespace } = relatedMergeRequestsElement.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el: relatedMergeRequestsElement, - components: { - RelatedMergeRequests, - }, - store: createStore(), - render: (createElement) => - createElement('related-merge-requests', { - props: { endpoint, projectNamespace, projectPath }, - }), - }); + if (!el) { + return undefined; } + + const { endpoint, projectPath, projectNamespace } = el.dataset; + + return new Vue({ + el, + store: createStore(), + render: (createElement) => + createElement(RelatedMergeRequests, { + props: { endpoint, projectNamespace, projectPath }, + }), + }); } diff --git a/app/assets/javascripts/issues/sentry_error_stack_trace/index.js b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js deleted file mode 100644 index 8e9ee25e7a8..00000000000 --- a/app/assets/javascripts/issues/sentry_error_stack_trace/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import store from '~/error_tracking/store'; -import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; - -export default function initSentryErrorStacktrace() { - const sentryErrorStackTraceEl = document.querySelector('#js-sentry-error-stack-trace'); - if (sentryErrorStackTraceEl) { - const { issueStackTracePath } = sentryErrorStackTraceEl.dataset; - // eslint-disable-next-line no-new - new Vue({ - el: sentryErrorStackTraceEl, - components: { - SentryErrorStackTrace, - }, - store, - render: (createElement) => - createElement('sentry-error-stack-trace', { - props: { issueStackTracePath }, - }), - }); - } -} diff --git a/app/assets/javascripts/issues/show.js b/app/assets/javascripts/issues/show.js deleted file mode 100644 index e43e56d7b4e..00000000000 --- a/app/assets/javascripts/issues/show.js +++ /dev/null @@ -1,59 +0,0 @@ -import loadAwardsHandler from '~/awards_handler'; -import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; -import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; -import { IssuableType } from '~/vue_shared/issuable/show/constants'; -import Issue from '~/issues/issue'; -import { initIncidentApp, initIncidentHeaderActions } from '~/issues/show/incident'; -import { initIssuableApp, initIssueHeaderActions } from '~/issues/show/issue'; -import { parseIssuableData } from '~/issues/show/utils/parse_data'; -import initNotesApp from '~/notes'; -import { store } from '~/notes/stores'; -import initRelatedMergeRequestsApp from '~/issues/related_merge_requests'; -import initSentryErrorStackTraceApp from '~/issues/sentry_error_stack_trace'; -import ZenMode from '~/zen_mode'; - -export default function initShowIssue() { - initNotesApp(); - - const initialDataEl = document.getElementById('js-issuable-app'); - const { issueType, ...issuableData } = parseIssuableData(initialDataEl); - - switch (issueType) { - case IssuableType.Incident: - initIncidentApp(issuableData); - initIncidentHeaderActions(store); - break; - case IssuableType.Issue: - initIssuableApp(issuableData, store); - initIssueHeaderActions(store); - break; - default: - initIssueHeaderActions(store); - break; - } - - initIssuableHeaderWarnings(store); - initSentryErrorStackTraceApp(); - initRelatedMergeRequestsApp(); - - import(/* webpackChunkName: 'design_management' */ '~/design_management') - .then((module) => module.default()) - .catch(() => {}); - - new ZenMode(); // eslint-disable-line no-new - - if (issueType !== IssuableType.TestCase) { - const awardEmojiEl = document.getElementById('js-vue-awards-block'); - - new Issue(); // eslint-disable-line no-new - new ShortcutsIssuable(); // eslint-disable-line no-new - initIssuableSidebar(); - if (awardEmojiEl) { - import('~/emoji/awards_app') - .then((m) => m.default(awardEmojiEl)) - .catch(() => {}); - } else { - loadAwardsHandler(); - } - } -} diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index eeaf865a35f..0490728c6bc 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -6,7 +6,7 @@ import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/const import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; -import { IssueTypePath, IncidentTypePath, IncidentType, POLLING_DELAY } from '../constants'; +import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, INCIDENT_TYPE, POLLING_DELAY } from '../constants'; import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; import Service from '../services/index'; @@ -378,15 +378,15 @@ export default { .then((data) => { if ( !window.location.pathname.includes(data.web_url) && - issueState.issueType !== IncidentType + issueState.issueType !== INCIDENT_TYPE ) { visitUrl(data.web_url); } if (issueState.isDirty) { const URI = - issueState.issueType === IncidentType - ? data.web_url.replace(IssueTypePath, IncidentTypePath) + issueState.issueType === INCIDENT_TYPE + ? data.web_url.replace(ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH) : data.web_url; visitUrl(URI); } diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue index 9110a6924b4..75d0b9e5e76 100644 --- a/app/assets/javascripts/issues/show/components/fields/type.vue +++ b/app/assets/javascripts/issues/show/components/fields/type.vue @@ -2,7 +2,7 @@ import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { capitalize } from 'lodash'; import { __ } from '~/locale'; -import { IssuableTypes, IncidentType } from '../../constants'; +import { issuableTypes, INCIDENT_TYPE } from '../../constants'; import getIssueStateQuery from '../../queries/get_issue_state.query.graphql'; import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql'; @@ -12,7 +12,7 @@ export const i18n = { export default { i18n, - IssuableTypes, + issuableTypes, components: { GlFormGroup, GlIcon, @@ -45,7 +45,7 @@ export default { return capitalize(issueType); }, shouldShowIncident() { - return this.issueType === IncidentType || this.canCreateIncident; + return this.issueType === INCIDENT_TYPE || this.canCreateIncident; }, }, methods: { @@ -59,7 +59,7 @@ export default { }); }, isShown(type) { - return type.value !== IncidentType || this.shouldShowIncident; + return type.value !== INCIDENT_TYPE || this.shouldShowIncident; }, }, }; @@ -81,7 +81,7 @@ export default { toggle-class="dropdown-menu-toggle" > <gl-dropdown-item - v-for="type in $options.IssuableTypes" + v-for="type in $options.issuableTypes" v-show="isShown(type)" :key="type.value" :is-checked="issueState.issueType === type.value" diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 700ef92a0f3..8ba08472ea0 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -11,9 +11,8 @@ import { import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash, { FLASH_TYPES } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import { IssuableType } from '~/vue_shared/issuable/show/constants'; -import { IssuableStatus } from '~/issues/constants'; -import { IssueStateEvent } from '~/issues/show/constants'; +import { IssuableStatus, IssueType } from '~/issues/constants'; +import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; import { s__, __, sprintf } from '~/locale'; @@ -83,7 +82,7 @@ export default { default: '', }, issueType: { - default: IssuableType.Issue, + default: IssueType.Issue, }, newIssuePath: { default: '', @@ -106,8 +105,8 @@ export default { }, issueTypeText() { const issueTypeTexts = { - [IssuableType.Issue]: s__('HeaderAction|issue'), - [IssuableType.Incident]: s__('HeaderAction|incident'), + [IssueType.Issue]: s__('HeaderAction|issue'), + [IssueType.Incident]: s__('HeaderAction|incident'), }; return issueTypeTexts[this.issueType] ?? this.issueType; @@ -163,7 +162,7 @@ export default { input: { iid: this.iid.toString(), projectPath: this.projectPath, - stateEvent: this.isClosed ? IssueStateEvent.Reopen : IssueStateEvent.Close, + stateEvent: this.isClosed ? ISSUE_STATE_EVENT_REOPEN : ISSUE_STATE_EVENT_CLOSE, }, }, }) diff --git a/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue index 1530e9a15b5..1530e9a15b5 100644 --- a/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue +++ b/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue diff --git a/app/assets/javascripts/issues/show/constants.js b/app/assets/javascripts/issues/show/constants.js index 35f3bcdad70..a100aaf88ad 100644 --- a/app/assets/javascripts/issues/show/constants.js +++ b/app/assets/javascripts/issues/show/constants.js @@ -1,22 +1,20 @@ import { __ } from '~/locale'; -export const IssueStateEvent = { - Close: 'CLOSE', - Reopen: 'REOPEN', -}; - -export const STATUS_PAGE_PUBLISHED = __('Published on status page'); +export const INCIDENT_TYPE = 'incident'; +export const INCIDENT_TYPE_PATH = 'issues/incident'; +export const ISSUE_STATE_EVENT_CLOSE = 'CLOSE'; +export const ISSUE_STATE_EVENT_REOPEN = 'REOPEN'; +export const ISSUE_TYPE_PATH = 'issues'; export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); +export const POLLING_DELAY = 2000; +export const STATUS_PAGE_PUBLISHED = __('Published on status page'); -export const IssuableTypes = [ +export const issuableTypes = [ { value: 'issue', text: __('Issue'), icon: 'issue-type-issue' }, { value: 'incident', text: __('Incident'), icon: 'issue-type-incident' }, ]; -export const IssueTypePath = 'issues'; -export const IncidentTypePath = 'issues/incident'; -export const IncidentType = 'incident'; - -export const issueState = { issueType: undefined, isDirty: false }; - -export const POLLING_DELAY = 2000; +export const issueState = { + issueType: undefined, + isDirty: false, +}; diff --git a/app/assets/javascripts/issues/show/incident.js b/app/assets/javascripts/issues/show/index.js index a260c31e1da..7f5a0e32f72 100644 --- a/app/assets/javascripts/issues/show/incident.js +++ b/app/assets/javascripts/issues/show/index.js @@ -1,11 +1,15 @@ import Vue from 'vue'; +import { mapGetters } from 'vuex'; +import errorTrackingStore from '~/error_tracking/store'; import { parseBoolean } from '~/lib/utils/common_utils'; -import issuableApp from './components/app.vue'; -import incidentTabs from './components/incidents/incident_tabs.vue'; -import { issueState, IncidentType } from './constants'; +import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; +import IssueApp from './components/app.vue'; +import HeaderActions from './components/header_actions.vue'; +import IncidentTabs from './components/incidents/incident_tabs.vue'; +import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; +import { INCIDENT_TYPE, issueState } from './constants'; import apolloProvider from './graphql'; import getIssueStateQuery from './queries/get_issue_state.query.graphql'; -import HeaderActions from './components/header_actions.vue'; const bootstrapApollo = (state = {}) => { return apolloProvider.clients.defaultClient.cache.writeQuery({ @@ -16,7 +20,7 @@ const bootstrapApollo = (state = {}) => { }); }; -export function initIncidentApp(issuableData = {}) { +export function initIncidentApp(issueData = {}) { const el = document.getElementById('js-issuable-app'); if (!el) { @@ -34,18 +38,15 @@ export function initIncidentApp(issuableData = {}) { projectId, slaFeatureAvailable, uploadMetricsFeatureAvailable, - } = issuableData; + } = issueData; const fullPath = `${projectNamespace}/${projectPath}`; return new Vue({ el, apolloProvider, - components: { - issuableApp, - }, provide: { - issueType: IncidentType, + issueType: INCIDENT_TYPE, canCreateIncident, canUpdate, fullPath, @@ -55,10 +56,10 @@ export function initIncidentApp(issuableData = {}) { uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), }, render(createElement) { - return createElement('issuable-app', { + return createElement(IssueApp, { props: { - ...issuableData, - descriptionComponent: incidentTabs, + ...issueData, + descriptionComponent: IncidentTabs, showTitleBorder: false, }, }); @@ -66,7 +67,46 @@ export function initIncidentApp(issuableData = {}) { }); } -export function initIncidentHeaderActions(store) { +export function initIssueApp(issueData, store) { + const el = document.getElementById('js-issuable-app'); + + if (!el) { + return undefined; + } + + if (gon?.features?.fixCommentScroll) { + scrollToTargetOnResize(); + } + + bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + + const { canCreateIncident, ...issueProps } = issueData; + + return new Vue({ + el, + apolloProvider, + store, + provide: { + canCreateIncident, + }, + computed: { + ...mapGetters(['getNoteableData']), + }, + render(createElement) { + return createElement(IssueApp, { + props: { + ...issueProps, + isConfidential: this.getNoteableData?.confidential, + isLocked: this.getNoteableData?.discussion_locked, + issuableStatus: this.getNoteableData?.state, + id: this.getNoteableData?.id, + }, + }); + }, + }); +} + +export function initHeaderActions(store, type = '') { const el = document.querySelector('.js-issue-header-actions'); if (!el) { @@ -75,12 +115,15 @@ export function initIncidentHeaderActions(store) { bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + const canCreate = + type === INCIDENT_TYPE ? el.dataset.canCreateIncident : el.dataset.canCreateIssue; + return new Vue({ el, apolloProvider, store, provide: { - canCreateIssue: parseBoolean(el.dataset.canCreateIncident), + canCreateIssue: parseBoolean(canCreate), canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), canReopenIssue: parseBoolean(el.dataset.canReopenIssue), @@ -99,3 +142,20 @@ export function initIncidentHeaderActions(store) { render: (createElement) => createElement(HeaderActions), }); } + +export function initSentryErrorStackTrace() { + const el = document.querySelector('#js-sentry-error-stack-trace'); + + if (!el) { + return undefined; + } + + const { issueStackTracePath } = el.dataset; + + return new Vue({ + el, + store: errorTrackingStore, + render: (createElement) => + createElement(SentryErrorStackTrace, { props: { issueStackTracePath } }), + }); +} diff --git a/app/assets/javascripts/issues/show/issue.js b/app/assets/javascripts/issues/show/issue.js deleted file mode 100644 index 60e90934af8..00000000000 --- a/app/assets/javascripts/issues/show/issue.js +++ /dev/null @@ -1,86 +0,0 @@ -import Vue from 'vue'; -import { mapGetters } from 'vuex'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import IssuableApp from './components/app.vue'; -import HeaderActions from './components/header_actions.vue'; -import { issueState } from './constants'; -import apolloProvider from './graphql'; -import getIssueStateQuery from './queries/get_issue_state.query.graphql'; - -const bootstrapApollo = (state = {}) => { - return apolloProvider.clients.defaultClient.cache.writeQuery({ - query: getIssueStateQuery, - data: { - issueState: state, - }, - }); -}; - -export function initIssuableApp(issuableData, store) { - const el = document.getElementById('js-issuable-app'); - - if (!el) { - return undefined; - } - - bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); - - const { canCreateIncident, ...issuableProps } = issuableData; - - return new Vue({ - el, - apolloProvider, - store, - provide: { - canCreateIncident, - }, - computed: { - ...mapGetters(['getNoteableData']), - }, - render(createElement) { - return createElement(IssuableApp, { - props: { - ...issuableProps, - isConfidential: this.getNoteableData?.confidential, - isLocked: this.getNoteableData?.discussion_locked, - issuableStatus: this.getNoteableData?.state, - id: this.getNoteableData?.id, - }, - }); - }, - }); -} - -export function initIssueHeaderActions(store) { - const el = document.querySelector('.js-issue-header-actions'); - - if (!el) { - return undefined; - } - - bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); - - return new Vue({ - el, - apolloProvider, - store, - provide: { - canCreateIssue: parseBoolean(el.dataset.canCreateIssue), - canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), - canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), - canReopenIssue: parseBoolean(el.dataset.canReopenIssue), - canReportSpam: parseBoolean(el.dataset.canReportSpam), - canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), - iid: el.dataset.iid, - isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), - issuePath: el.dataset.issuePath, - issueType: el.dataset.issueType, - newIssuePath: el.dataset.newIssuePath, - projectPath: el.dataset.projectPath, - projectId: el.dataset.projectId, - reportAbusePath: el.dataset.reportAbusePath, - submitAsSpamPath: el.dataset.submitAsSpamPath, - }, - render: (createElement) => createElement(HeaderActions), - }); -} |