summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/issues
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-01-20 09:16:11 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-01-20 09:16:11 +0000
commitedaa33dee2ff2f7ea3fac488d41558eb5f86d68c (patch)
tree11f143effbfeba52329fb7afbd05e6e2a3790241 /app/assets/javascripts/issues
parentd8a5691316400a0f7ec4f83832698f1988eb27c1 (diff)
downloadgitlab-ce-14.7.0-rc42.tar.gz
Add latest changes from gitlab-org/gitlab@14-7-stable-eev14.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/issues')
-rw-r--r--app/assets/javascripts/issues/constants.js6
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js566
-rw-r--r--app/assets/javascripts/issues/form.js24
-rw-r--r--app/assets/javascripts/issues/index.js88
-rw-r--r--app/assets/javascripts/issues/init_filtered_search_service_desk.js11
-rw-r--r--app/assets/javascripts/issues/issue.js2
-rw-r--r--app/assets/javascripts/issues/list/components/issue_card_time_info.vue104
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue821
-rw-r--r--app/assets/javascripts/issues/list/components/jira_issues_import_status_app.vue112
-rw-r--r--app/assets/javascripts/issues/list/components/new_issue_dropdown.vue127
-rw-r--r--app/assets/javascripts/issues/list/constants.js316
-rw-r--r--app/assets/javascripts/issues/list/eventhub.js3
-rw-r--r--app/assets/javascripts/issues/list/index.js165
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues.query.graphql90
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql129
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues_list_details.query.graphql24
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql54
-rw-r--r--app/assets/javascripts/issues/list/queries/label.fragment.graphql6
-rw-r--r--app/assets/javascripts/issues/list/queries/milestone.fragment.graphql4
-rw-r--r--app/assets/javascripts/issues/list/queries/reorder_issues.mutation.graphql13
-rw-r--r--app/assets/javascripts/issues/list/queries/search_labels.query.graphql20
-rw-r--r--app/assets/javascripts/issues/list/queries/search_milestones.query.graphql20
-rw-r--r--app/assets/javascripts/issues/list/queries/search_projects.query.graphql14
-rw-r--r--app/assets/javascripts/issues/list/queries/search_users.query.graphql26
-rw-r--r--app/assets/javascripts/issues/list/queries/user.fragment.graphql6
-rw-r--r--app/assets/javascripts/issues/list/utils.js261
-rw-r--r--app/assets/javascripts/issues/manual_ordering.js6
-rw-r--r--app/assets/javascripts/issues/new/index.js4
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/index.js32
-rw-r--r--app/assets/javascripts/issues/sentry_error_stack_trace/index.js22
-rw-r--r--app/assets/javascripts/issues/show.js59
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue8
-rw-r--r--app/assets/javascripts/issues/show/components/fields/type.vue10
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue13
-rw-r--r--app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue (renamed from app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue)0
-rw-r--r--app/assets/javascripts/issues/show/constants.js26
-rw-r--r--app/assets/javascripts/issues/show/index.js (renamed from app/assets/javascripts/issues/show/incident.js)90
-rw-r--r--app/assets/javascripts/issues/show/issue.js86
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),
- });
-}