Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/issues')
-rw-r--r--app/assets/javascripts/issues/constants.js1
-rw-r--r--app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue1
-rw-r--r--app/assets/javascripts/issues/index.js38
-rw-r--r--app/assets/javascripts/issues/issue.js5
-rw-r--r--app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue2
-rw-r--r--app/assets/javascripts/issues/list/components/issue_card_time_info.vue40
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue49
-rw-r--r--app/assets/javascripts/issues/list/constants.js1
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql1
-rw-r--r--app/assets/javascripts/issues/list/queries/search_milestones.query.graphql11
-rw-r--r--app/assets/javascripts/issues/list/queries/search_users.query.graphql29
-rw-r--r--app/assets/javascripts/issues/list/queries/user.fragment.graphql6
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue9
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/index.js10
-rw-r--r--app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue59
-rw-r--r--app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue74
-rw-r--r--app/assets/javascripts/issues/service_desk/components/info_banner.vue64
-rw-r--r--app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue599
-rw-r--r--app/assets/javascripts/issues/service_desk/constants.js254
-rw-r--r--app/assets/javascripts/issues/service_desk/graphql.js24
-rw-r--r--app/assets/javascripts/issues/service_desk/index.js82
-rw-r--r--app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues.query.graphql67
-rw-r--r--app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql82
-rw-r--r--app/assets/javascripts/issues/service_desk/queries/issue.fragment.graphql61
-rw-r--r--app/assets/javascripts/issues/service_desk/queries/label.fragment.graphql6
-rw-r--r--app/assets/javascripts/issues/service_desk/queries/milestone.fragment.graphql4
-rw-r--r--app/assets/javascripts/issues/service_desk/queries/reorder_service_desk_issues.mutation.graphql13
-rw-r--r--app/assets/javascripts/issues/service_desk/queries/search_project_labels.query.graphql14
-rw-r--r--app/assets/javascripts/issues/service_desk/queries/search_project_milestones.query.graphql17
-rw-r--r--app/assets/javascripts/issues/service_desk/queries/set_sorting_preference.mutation.graphql5
-rw-r--r--app/assets/javascripts/issues/service_desk/search_tokens.js97
-rw-r--r--app/assets/javascripts/issues/service_desk/utils.js37
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue202
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue3
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue79
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue5
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/sticky_header.vue130
-rw-r--r--app/assets/javascripts/issues/show/components/task_list_item_actions.vue17
-rw-r--r--app/assets/javascripts/issues/show/index.js190
-rw-r--r--app/assets/javascripts/issues/show/stores/index.js46
41 files changed, 1915 insertions, 521 deletions
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index 0a1a1324d7d..80344efc44c 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -30,6 +30,7 @@ export const issuableStatusText = {
export const IssuableTypeText = {
[TYPE_ISSUE]: __('issue'),
+ [TYPE_EPIC]: __('epic'),
[TYPE_MERGE_REQUEST]: __('merge request'),
[TYPE_ALERT]: __('alert'),
[TYPE_INCIDENT]: __('incident'),
diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
index 9febebf7e55..a756229e6ca 100644
--- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
+++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
@@ -495,7 +495,6 @@ export default {
:issuables-loading="isLoading"
namespace="dashboard"
recent-searches-storage-key="issues"
- :search-input-placeholder="$options.i18n.searchPlaceholder"
:search-tokens="searchTokens"
:show-pagination-controls="showPaginationControls"
show-work-item-type-icon
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index eec7c6bf842..3bd28c50800 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -3,21 +3,20 @@ import IssuableForm from 'ee_else_ce/issuable/issuable_form';
import IssuableLabelSelector from '~/issuable/issuable_label_selector';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable';
-import { TYPE_INCIDENT } from '~/issues/constants';
+import { initIssuableSidebar } from '~/issuable';
import Issue from '~/issues/issue';
import { initTitleSuggestions, initTypePopover, initTypeSelect } from '~/issues/new';
import { initRelatedMergeRequests } from '~/issues/related_merge_requests';
import { initRelatedIssues } from '~/related_issues';
-import { initIncidentApp, initIssueApp, initSentryErrorStackTrace } from '~/issues/show';
-import { parseIssuableData } from '~/issues/show/utils/parse_data';
+import { initIssuableApp, initSentryErrorStackTrace } from '~/issues/show';
import LabelsSelect from '~/labels/labels_select';
import initNotesApp from '~/notes';
import { store } from '~/notes/stores';
import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar';
+import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import initWorkItemLinks from '~/work_items/components/work_item_links';
import ZenMode from '~/zen_mode';
import initAwardsApp from '~/emoji/awards_app';
-import initLinkedResources from '~/linked_resources';
import FilteredSearchServiceDesk from './filtered_search_service_desk';
export function initFilteredSearchServiceDesk() {
@@ -42,33 +41,20 @@ export function initForm() {
mountMilestoneDropdown();
}
-export function initShow({ notesParams } = {}) {
- const el = document.getElementById('js-issuable-app');
-
- if (!el) {
- return;
- }
-
- const { issueType, ...issuableData } = parseIssuableData(el);
-
- if (issueType === TYPE_INCIDENT) {
- initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }, store);
- initLinkedResources();
- initRelatedIssues(TYPE_INCIDENT);
- } else {
- initIssueApp(issuableData, store);
- }
-
+export function initShow() {
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
- initIssuableHeaderWarnings(store);
+
+ initAwardsApp(document.getElementById('js-vue-awards-block'));
+ initIssuableApp(store);
initIssuableSidebar();
- initNotesApp(notesParams);
+ initNotesApp();
+ initRelatedIssues();
initRelatedMergeRequests();
initSentryErrorStackTrace();
-
- initAwardsApp(document.getElementById('js-vue-awards-block'));
+ initSidebarBundle(store);
+ initWorkItemLinks();
import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then((module) => module.default())
diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js
index b7fd99d8042..06bbcdc12ea 100644
--- a/app/assets/javascripts/issues/issue.js
+++ b/app/assets/javascripts/issues/issue.js
@@ -49,13 +49,8 @@ export default class Issue {
issueFailMessage = __('Unable to update this issue at this time.'),
) {
if ('id' in data) {
- const isClosedBadge = $('.issuable-status-badge-closed');
- const isOpenBadge = $('.issuable-status-badge-open');
const projectIssuesCounter = $('.issue_counter');
- isClosedBadge.toggleClass('hidden', !isClosed);
- isOpenBadge.toggleClass('hidden', isClosed);
-
$(document).trigger('issuable:change', isClosed);
let numProjectIssues = Number(
diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
index 9f7fca0ceca..3d62ea07f59 100644
--- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
+++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
@@ -82,7 +82,7 @@ export default {
v-if="showCsvButtons"
class="gl-w-full gl-sm-w-auto gl-sm-mr-3"
:toggle-text="$options.i18n.importIssues"
- data-qa-selector="import_issues_dropdown"
+ data-testid="import-issues-dropdown"
>
<csv-import-export-buttons
:export-csv-path="exportCsvPathWithQuery"
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
index dde1a4fd2d6..22c0984ebdb 100644
--- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
@@ -10,6 +10,8 @@ import {
newDateAsLocaleTime,
} from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
+import { STATE_CLOSED } from '~/work_items/constants';
+import { isMilestoneWidget, isStartAndDueDateWidget } from '~/work_items/utils';
export default {
components: {
@@ -26,9 +28,12 @@ export default {
},
},
computed: {
+ milestone() {
+ return this.issue.milestone || this.issue.widgets?.find(isMilestoneWidget)?.milestone;
+ },
milestoneDate() {
- if (this.issue.milestone?.dueDate) {
- const { dueDate, startDate } = this.issue.milestone;
+ if (this.milestone.dueDate) {
+ const { dueDate, startDate } = this.milestone;
const date = dateInWords(newDateAsLocaleTime(dueDate), true);
const remainingTime = this.milestoneRemainingTime(dueDate, startDate);
return `${date} (${remainingTime})`;
@@ -36,15 +41,19 @@ export default {
return __('Milestone');
},
milestoneLink() {
- return this.issue.milestone.webPath || this.issue.milestone.webUrl;
+ return this.milestone.webPath || this.milestone.webUrl;
},
dueDate() {
- return this.issue.dueDate && dateInWords(newDateAsLocaleTime(this.issue.dueDate), true);
+ return this.issue.dueDate || this.issue.widgets?.find(isStartAndDueDateWidget)?.dueDate;
+ },
+ dueDateText() {
+ return this.dueDate && dateInWords(newDateAsLocaleTime(this.dueDate), true);
+ },
+ isClosed() {
+ return this.issue.state === STATUS_CLOSED || this.issue.state === STATE_CLOSED;
},
showDueDateInRed() {
- return (
- isInPast(newDateAsLocaleTime(this.issue.dueDate)) && this.issue.state !== STATUS_CLOSED
- );
+ return isInPast(newDateAsLocaleTime(this.dueDate)) && !this.isClosed;
},
timeEstimate() {
return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate;
@@ -57,11 +66,14 @@ export default {
if (dueDate && isInPast(due)) {
return __('Past due');
- } else if (dueDate && isToday(due)) {
+ }
+ if (dueDate && isToday(due)) {
return __('Today');
- } else if (startDate && isInFuture(start)) {
+ }
+ if (startDate && isInFuture(start)) {
return __('Upcoming');
- } else if (dueDate) {
+ }
+ if (dueDate) {
return getTimeRemainingInWords(due);
}
return '';
@@ -73,7 +85,7 @@ export default {
<template>
<span>
<span
- v-if="issue.milestone"
+ v-if="milestone"
class="issuable-milestone gl-mr-3 gl-text-truncate gl-max-w-26 gl-display-inline-block gl-vertical-align-bottom"
data-testid="issuable-milestone"
>
@@ -84,11 +96,11 @@ export default {
class="gl-font-sm gl-text-gray-500!"
>
<gl-icon name="clock" :size="12" />
- {{ issue.milestone.title }}
+ {{ milestone.title }}
</gl-link>
</span>
<span
- v-if="issue.dueDate"
+ v-if="dueDate"
v-gl-tooltip
class="issuable-due-date gl-mr-3"
:class="{ 'gl-text-red-500': showDueDateInRed }"
@@ -96,7 +108,7 @@ export default {
data-testid="issuable-due-date"
>
<gl-icon name="calendar" :size="12" />
- {{ dueDate }}
+ {{ dueDateText }}
</span>
<span
v-if="timeEstimate"
diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue
index c50b48ca0d8..3d8ed3af816 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -99,8 +99,6 @@ import {
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 setSortPreferenceMutation from '../queries/set_sort_preference.mutation.graphql';
import {
convertToApiParams,
@@ -204,11 +202,6 @@ export default {
required: false,
default: () => [],
},
- eeIsOkrsEnabled: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
@@ -411,9 +404,10 @@ export default {
title: TOKEN_TITLE_MILESTONE,
icon: 'clock',
token: MilestoneToken,
- fetchMilestones: this.fetchMilestones,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`,
shouldSkipSort: true,
+ fullPath: this.fullPath,
+ isProject: this.isProject,
},
{
type: TOKEN_TYPE_LABEL,
@@ -640,32 +634,13 @@ export default {
fetchLatestLabels(search) {
return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY);
},
- 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) {
- if (gon.features?.newGraphqlUsersAutocomplete) {
- return this.$apollo
- .query({
- query: usersAutocompleteQuery,
- variables: { fullPath: this.fullPath, search, isProject: this.isProject },
- })
- .then(({ data }) => data[this.namespace]?.autocompleteUsers);
- }
-
return this.$apollo
.query({
- query: searchUsersQuery,
+ query: usersAutocompleteQuery,
variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
- .then(({ data }) =>
- data[this.namespace]?.[`${this.namespace}Members`].nodes.map((member) => member.user),
- );
+ .then(({ data }) => data[this.namespace]?.autocompleteUsers);
},
getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`;
@@ -966,7 +941,6 @@ export default {
v-if="hasAnyIssues"
:namespace="fullPath"
recent-searches-storage-key="issues"
- :search-input-placeholder="$options.i18n.searchPlaceholder"
:search-tokens="searchTokens"
:has-scoped-labels-feature="hasScopedLabelsFeature"
:initial-filter-value="filterTokens"
@@ -1037,14 +1011,11 @@ export default {
>
{{ $options.i18n.editIssues }}
</gl-button>
- <gl-button
- v-if="showNewIssueLink && !eeIsOkrsEnabled"
- :href="newIssuePath"
- variant="confirm"
- >
- {{ $options.i18n.newIssueLabel }}
- </gl-button>
- <slot name="new-objective-button"></slot>
+ <slot name="new-issuable-button">
+ <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
+ {{ $options.i18n.newIssueLabel }}
+ </gl-button>
+ </slot>
<new-resource-dropdown
v-if="showNewIssueDropdown"
:query="$options.searchProjectsQuery"
@@ -1059,7 +1030,7 @@ export default {
no-caret
:toggle-text="$options.i18n.actionsLabel"
text-sr-only
- data-qa-selector="issues_list_more_actions_dropdown"
+ data-testid="issues-list-more-actions-dropdown"
>
<csv-import-export-buttons
v-if="showCsvButtons"
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 85e300b6474..682c7629962 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -121,7 +121,6 @@ export const i18n = {
reorderError: __('An error occurred while reordering issues.'),
deleteError: __('An error occurred while deleting an issuable.'),
rssLabel: __('Subscribe to RSS feed'),
- searchPlaceholder: __('Search or filter results...'),
upvotes: __('Upvotes'),
titles: __('Titles'),
descriptions: __('Descriptions'),
diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
index 3b49c0efb14..f3173f0e33a 100644
--- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
@@ -11,6 +11,7 @@ fragment IssueFragment on Issue {
moved
state
title
+ titleHtml
updatedAt
closedAt
upvotes
diff --git a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
index 040240cde99..941e71b7ca7 100644
--- a/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
@@ -1,6 +1,11 @@
#import "./milestone.fragment.graphql"
-query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) {
+query searchMilestones(
+ $fullPath: ID!
+ $search: String
+ $isProject: Boolean = false
+ $state: MilestoneStateEnum
+) {
group(fullPath: $fullPath) @skip(if: $isProject) {
id
milestones(
@@ -8,7 +13,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa
includeAncestors: true
includeDescendants: true
sort: EXPIRED_LAST_DUE_DATE_ASC
- state: active
+ state: $state
) {
nodes {
...Milestone
@@ -21,7 +26,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa
searchTitle: $search
includeAncestors: true
sort: EXPIRED_LAST_DUE_DATE_ASC
- state: active
+ state: $state
) {
nodes {
...Milestone
diff --git a/app/assets/javascripts/issues/list/queries/search_users.query.graphql b/app/assets/javascripts/issues/list/queries/search_users.query.graphql
deleted file mode 100644
index 6a1967a8875..00000000000
--- a/app/assets/javascripts/issues/list/queries/search_users.query.graphql
+++ /dev/null
@@ -1,29 +0,0 @@
-#import "./user.fragment.graphql"
-
-query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) {
- group(fullPath: $fullPath) @skip(if: $isProject) {
- id
- groupMembers(search: $search, relations: [DIRECT, INHERITED, SHARED_FROM_GROUPS]) {
- nodes {
- id
- user {
- ...User
- }
- }
- }
- }
- project(fullPath: $fullPath) @include(if: $isProject) {
- id
- projectMembers(
- search: $search
- relations: [DIRECT, INHERITED, INVITED_GROUPS, SHARED_INTO_ANCESTORS]
- ) {
- 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
deleted file mode 100644
index 3e5bc0f7b93..00000000000
--- a/app/assets/javascripts/issues/list/queries/user.fragment.graphql
+++ /dev/null
@@ -1,6 +0,0 @@
-fragment User on User {
- id
- avatarUrl
- name
- username
-}
diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
index d819a371c69..5e81f7ad4f6 100644
--- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
@@ -4,7 +4,6 @@ import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { sprintf, __, n__ } from '~/locale';
import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
-import { parseIssuableData } from '~/issues/show/utils/parse_data';
export default {
name: 'RelatedMergeRequests',
@@ -19,6 +18,11 @@ export default {
type: String,
required: true,
},
+ hasClosingMergeRequest: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
projectNamespace: {
type: String,
required: true,
@@ -48,9 +52,6 @@ export default {
this.setInitialState({ apiEndpoint: this.endpoint });
this.fetchMergeRequests();
},
- created() {
- this.hasClosingMergeRequest = parseIssuableData().hasClosingMergeRequest;
- },
methods: {
...mapActions(['setInitialState', 'fetchMergeRequests']),
getAssignees(mr) {
diff --git a/app/assets/javascripts/issues/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js
index 196084093c8..413b48b9720 100644
--- a/app/assets/javascripts/issues/related_merge_requests/index.js
+++ b/app/assets/javascripts/issues/related_merge_requests/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import RelatedMergeRequests from './components/related_merge_requests.vue';
import createStore from './store';
@@ -9,7 +10,7 @@ export function initRelatedMergeRequests() {
return undefined;
}
- const { endpoint, projectPath, projectNamespace } = el.dataset;
+ const { endpoint, hasClosingMergeRequest, projectPath, projectNamespace } = el.dataset;
return new Vue({
el,
@@ -17,7 +18,12 @@ export function initRelatedMergeRequests() {
store: createStore(),
render: (createElement) =>
createElement(RelatedMergeRequests, {
- props: { endpoint, projectNamespace, projectPath },
+ props: {
+ endpoint,
+ hasClosingMergeRequest: parseBoolean(hasClosingMergeRequest),
+ projectNamespace,
+ projectPath,
+ },
}),
});
}
diff --git a/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue b/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue
new file mode 100644
index 00000000000..ab9e70ae223
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import {
+ noSearchResultsTitle,
+ noSearchResultsDescription,
+ infoBannerUserNote,
+ noOpenIssuesTitle,
+ noClosedIssuesTitle,
+} from '../constants';
+
+export default {
+ i18n: {
+ noSearchResultsTitle,
+ noSearchResultsDescription,
+ infoBannerUserNote,
+ noOpenIssuesTitle,
+ noClosedIssuesTitle,
+ },
+ components: {
+ GlEmptyState,
+ },
+ inject: ['emptyStateSvgPath'],
+ props: {
+ hasSearch: {
+ type: Boolean,
+ required: true,
+ },
+ isOpenTab: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ content() {
+ if (this.hasSearch) {
+ return {
+ title: noSearchResultsTitle,
+ description: noSearchResultsDescription,
+ svgHeight: 150,
+ };
+ }
+ if (this.isOpenTab) {
+ return { title: noOpenIssuesTitle, description: infoBannerUserNote };
+ }
+
+ return { title: noClosedIssuesTitle, svgHeight: 150 };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :description="content.description"
+ :title="content.title"
+ :svg-path="emptyStateSvgPath"
+ :svg-height="content.svgHeight"
+ />
+</template>
diff --git a/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue
new file mode 100644
index 00000000000..9dbed2c2579
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlEmptyState, GlLink } from '@gitlab/ui';
+import {
+ noIssuesSignedOutButtonText,
+ infoBannerTitle,
+ infoBannerUserNote,
+ infoBannerAdminNote,
+ learnMore,
+} from '../constants';
+
+export default {
+ i18n: {
+ noIssuesSignedOutButtonText,
+ infoBannerTitle,
+ infoBannerUserNote,
+ infoBannerAdminNote,
+ learnMore,
+ },
+ components: {
+ GlEmptyState,
+ GlLink,
+ },
+ inject: [
+ 'emptyStateSvgPath',
+ 'isSignedIn',
+ 'signInPath',
+ 'canAdminIssues',
+ 'isServiceDeskEnabled',
+ 'serviceDeskEmailAddress',
+ 'serviceDeskHelpPath',
+ ],
+ computed: {
+ canSeeEmailAddress() {
+ return this.canAdminIssues && this.isServiceDeskEnabled;
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="isSignedIn">
+ <gl-empty-state
+ :title="$options.i18n.infoBannerTitle"
+ :svg-path="emptyStateSvgPath"
+ content-class="gl-max-w-80!"
+ >
+ <template #description>
+ <p v-if="canSeeEmailAddress">
+ {{ $options.i18n.infoBannerAdminNote }} <br /><code>{{ serviceDeskEmailAddress }}</code>
+ </p>
+ <p>{{ $options.i18n.infoBannerUserNote }}</p>
+ <gl-link :href="serviceDeskHelpPath">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </template>
+ </gl-empty-state>
+ </div>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.infoBannerTitle"
+ :svg-path="emptyStateSvgPath"
+ :primary-button-text="$options.i18n.noIssuesSignedOutButtonText"
+ :primary-button-link="signInPath"
+ content-class="gl-max-w-80!"
+ >
+ <template #description>
+ <p>{{ $options.i18n.infoBannerUserNote }}</p>
+ <gl-link :href="serviceDeskHelpPath">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/issues/service_desk/components/info_banner.vue b/app/assets/javascripts/issues/service_desk/components/info_banner.vue
new file mode 100644
index 00000000000..5667ee2f31d
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/components/info_banner.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlLink, GlButton } from '@gitlab/ui';
+import {
+ infoBannerTitle,
+ infoBannerAdminNote,
+ infoBannerUserNote,
+ enableServiceDesk,
+ learnMore,
+} from '../constants';
+
+export default {
+ name: 'InfoBanner',
+ components: {
+ GlLink,
+ GlButton,
+ },
+ inject: [
+ 'serviceDeskCalloutSvgPath',
+ 'serviceDeskEmailAddress',
+ 'canAdminIssues',
+ 'canEditProjectSettings',
+ 'serviceDeskSettingsPath',
+ 'isServiceDeskEnabled',
+ 'serviceDeskHelpPath',
+ ],
+ i18n: { infoBannerTitle, infoBannerAdminNote, infoBannerUserNote, enableServiceDesk, learnMore },
+ computed: {
+ canSeeEmailAddress() {
+ return this.canAdminIssues && this.isServiceDeskEnabled;
+ },
+ canEnableServiceDesk() {
+ return this.canEditProjectSettings && !this.isServiceDeskEnabled;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-border-b gl-pb-3 gl-display-flex gl-align-items-flex-start">
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <img
+ :src="serviceDeskCalloutSvgPath"
+ alt=""
+ class="gl-display-none gl-sm-display-block gl-p-5"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ <div class="gl-mt-3 gl-ml-3">
+ <h5>{{ $options.i18n.infoBannerTitle }}</h5>
+ <p v-if="canSeeEmailAddress">
+ {{ $options.i18n.infoBannerAdminNote }} <code>{{ serviceDeskEmailAddress }}</code>
+ </p>
+ <p>
+ {{ $options.i18n.infoBannerUserNote }}
+ <gl-link :href="serviceDeskHelpPath">{{ $options.i18n.learnMore }}</gl-link
+ >.
+ </p>
+ <p v-if="canEnableServiceDesk" class="gl-mt-3">
+ <gl-button :href="serviceDeskSettingsPath" variant="confirm">{{
+ $options.i18n.enableServiceDesk
+ }}</gl-button>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue
new file mode 100644
index 00000000000..4b59672428b
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue
@@ -0,0 +1,599 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { isEmpty } from 'lodash';
+import { fetchPolicies } from '~/lib/graphql';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
+import axios from '~/lib/utils/axios_utils';
+import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
+import { scrollUp } from '~/lib/utils/scroll_utils';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
+import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants';
+import {
+ convertToSearchQuery,
+ convertToApiParams,
+ getInitialPageParams,
+ getFilterTokens,
+ isSortKey,
+ getSortOptions,
+ getSortKey,
+} from '~/issues/list/utils';
+import {
+ OPERATORS_IS_NOT,
+ OPERATORS_IS_NOT_OR,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ MAX_LIST_SIZE,
+ ISSUE_REFERENCE,
+ PARAM_STATE,
+ PARAM_FIRST_PAGE_SIZE,
+ PARAM_LAST_PAGE_SIZE,
+ PARAM_PAGE_AFTER,
+ PARAM_PAGE_BEFORE,
+ PARAM_SORT,
+ CREATED_DESC,
+ UPDATED_DESC,
+ RELATIVE_POSITION_ASC,
+ urlSortParams,
+} from '~/issues/list/constants';
+import { createAlert, VARIANT_INFO } from '~/alert';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
+import searchProjectMembers from '~/graphql_shared/queries/project_user_members_search.query.graphql';
+import getServiceDeskIssuesQuery from 'ee_else_ce/issues/service_desk/queries/get_service_desk_issues.query.graphql';
+import getServiceDeskIssuesCounts from 'ee_else_ce/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql';
+import searchProjectLabelsQuery from '../queries/search_project_labels.query.graphql';
+import searchProjectMilestonesQuery from '../queries/search_project_milestones.query.graphql';
+import setSortingPreferenceMutation from '../queries/set_sorting_preference.mutation.graphql';
+import reorderServiceDeskIssuesMutation from '../queries/reorder_service_desk_issues.mutation.graphql';
+import {
+ errorFetchingCounts,
+ errorFetchingIssues,
+ issueRepositioningMessage,
+ reorderError,
+ SERVICE_DESK_BOT_USERNAME,
+ STATUS_OPEN,
+ STATUS_CLOSED,
+ STATUS_ALL,
+ WORKSPACE_PROJECT,
+} from '../constants';
+import { convertToUrlParams } from '../utils';
+import {
+ searchWithinTokenBase,
+ assigneeTokenBase,
+ milestoneTokenBase,
+ labelTokenBase,
+ releaseTokenBase,
+ reactionTokenBase,
+ confidentialityTokenBase,
+} from '../search_tokens';
+import InfoBanner from './info_banner.vue';
+import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue';
+import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue';
+
+export default {
+ i18n: {
+ errorFetchingCounts,
+ errorFetchingIssues,
+ issueRepositioningMessage,
+ reorderError,
+ },
+ issuableListTabs,
+ components: {
+ IssuableList,
+ InfoBanner,
+ EmptyStateWithAnyIssues,
+ EmptyStateWithoutAnyIssues,
+ },
+ mixins: [glFeatureFlagMixin()],
+ inject: [
+ 'releasesPath',
+ 'autocompleteAwardEmojisPath',
+ 'hasBlockedIssuesFeature',
+ 'hasIterationsFeature',
+ 'hasIssueWeightsFeature',
+ 'hasIssuableHealthStatusFeature',
+ 'groupPath',
+ 'emptyStateSvgPath',
+ 'isProject',
+ 'isSignedIn',
+ 'fullPath',
+ 'isServiceDeskSupported',
+ 'hasAnyIssues',
+ 'initialSort',
+ 'isIssueRepositioningDisabled',
+ ],
+ props: {
+ eeSearchTokens: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ serviceDeskIssues: [],
+ serviceDeskIssuesCounts: {},
+ filterTokens: [],
+ pageInfo: {},
+ pageParams: {},
+ sortKey: CREATED_DESC,
+ state: STATUS_OPEN,
+ pageSize: DEFAULT_PAGE_SIZE,
+ issuesError: '',
+ };
+ },
+ apollo: {
+ serviceDeskIssues: {
+ query: getServiceDeskIssuesQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data.project.issues.nodes ?? [];
+ },
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ // We need this for handling loading state when using frontend cache
+ // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details
+ notifyOnNetworkStatusChange: true,
+ result({ data }) {
+ if (!data) {
+ return;
+ }
+ this.pageInfo = data?.project.issues.pageInfo ?? {};
+ },
+ error(error) {
+ this.issuesError = this.$options.i18n.errorFetchingIssues;
+ Sentry.captureException(error);
+ },
+ skip() {
+ return this.shouldSkipQuery;
+ },
+ },
+ serviceDeskIssuesCounts: {
+ query: getServiceDeskIssuesCounts,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data?.project ?? {};
+ },
+ error(error) {
+ this.issuesError = this.$options.i18n.errorFetchingCounts;
+ Sentry.captureException(error);
+ },
+ skip() {
+ return this.shouldSkipQuery;
+ },
+ context: {
+ isSingleRequest: true,
+ },
+ },
+ },
+ computed: {
+ queryVariables() {
+ const isIidSearch = ISSUE_REFERENCE.test(this.searchQuery);
+ return {
+ fullPath: this.fullPath,
+ iid: isIidSearch ? this.searchQuery.slice(1) : undefined,
+ isSignedIn: this.isSignedIn,
+ authorUsername: SERVICE_DESK_BOT_USERNAME,
+ sort: this.sortKey,
+ state: this.state,
+ ...this.pageParams,
+ ...this.apiFilterParams,
+ search: isIidSearch ? undefined : this.searchQuery,
+ };
+ },
+ shouldSkipQuery() {
+ return !this.hasAnyIssues || isEmpty(this.pageParams);
+ },
+ sortOptions() {
+ return getSortOptions({
+ hasBlockedIssuesFeature: this.hasBlockedIssuesFeature,
+ hasIssuableHealthStatusFeature: this.hasIssuableHealthStatusFeature,
+ hasIssueWeightsFeature: this.hasIssueWeightsFeature,
+ });
+ },
+ tabCounts() {
+ const { openedIssues, closedIssues, allIssues } = this.serviceDeskIssuesCounts;
+ return {
+ [STATUS_OPEN]: openedIssues?.count,
+ [STATUS_CLOSED]: closedIssues?.count,
+ [STATUS_ALL]: allIssues?.count,
+ };
+ },
+ currentTabCount() {
+ return this.tabCounts[this.state] ?? 0;
+ },
+ showPaginationControls() {
+ return (
+ this.serviceDeskIssues.length > 0 &&
+ (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage)
+ );
+ },
+ showPageSizeControls() {
+ return this.currentTabCount > DEFAULT_PAGE_SIZE;
+ },
+ isLoading() {
+ return this.$apollo.loading;
+ },
+ isOpenTab() {
+ return this.state === STATUS_OPEN;
+ },
+ urlParams() {
+ return {
+ sort: urlSortParams[this.sortKey],
+ state: this.state,
+ ...this.urlFilterParams,
+ first_page_size: this.pageParams.firstPageSize,
+ last_page_size: this.pageParams.lastPageSize,
+ page_after: this.pageParams.afterCursor ?? undefined,
+ page_before: this.pageParams.beforeCursor ?? undefined,
+ };
+ },
+ hasAnyServiceDeskIssue() {
+ return this.hasSearch || Boolean(this.tabCounts.all);
+ },
+ isInfoBannerVisible() {
+ return this.isServiceDeskSupported && this.hasAnyServiceDeskIssue;
+ },
+ canShowIssuesList() {
+ return this.isLoading || this.issuesError.length || this.hasAnyServiceDeskIssue;
+ },
+ hasOrFeature() {
+ return this.glFeatures.orIssuableQueries;
+ },
+ hasSearch() {
+ return Boolean(
+ this.searchQuery ||
+ Object.keys(this.urlFilterParams).length ||
+ this.pageParams.afterCursor ||
+ this.pageParams.beforeCursor,
+ );
+ },
+ apiFilterParams() {
+ return convertToApiParams(this.filterTokens);
+ },
+ urlFilterParams() {
+ return convertToUrlParams(this.filterTokens);
+ },
+ searchQuery() {
+ return convertToSearchQuery(this.filterTokens);
+ },
+ searchTokens() {
+ const preloadedUsers = [];
+
+ if (gon.current_user_id) {
+ preloadedUsers.push({
+ id: convertToGraphQLId(TYPENAME_USER, gon.current_user_id),
+ name: gon.current_user_fullname,
+ username: gon.current_username,
+ avatar_url: gon.current_user_avatar_url,
+ });
+ }
+
+ const tokens = [
+ {
+ ...searchWithinTokenBase,
+ },
+ {
+ ...assigneeTokenBase,
+ operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT,
+ fetchUsers: this.fetchUsers,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`,
+ preloadedUsers,
+ },
+ {
+ ...milestoneTokenBase,
+ fetchMilestones: this.fetchMilestones,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`,
+ },
+ {
+ ...labelTokenBase,
+ operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT,
+ fetchLabels: this.fetchLabels,
+ fetchLatestLabels: this.glFeatures.frontendCaching ? this.fetchLatestLabels : null,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`,
+ },
+ ];
+
+ if (this.isProject) {
+ tokens.push({
+ ...releaseTokenBase,
+ fetchReleases: this.fetchReleases,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-release`,
+ });
+ }
+
+ if (this.isSignedIn) {
+ tokens.push({
+ ...reactionTokenBase,
+ fetchEmojis: this.fetchEmojis,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-my_reaction`,
+ });
+
+ tokens.push({
+ ...confidentialityTokenBase,
+ });
+ }
+
+ if (this.eeSearchTokens.length) {
+ tokens.push(...this.eeSearchTokens);
+ }
+
+ tokens.sort((a, b) => a.title.localeCompare(b.title));
+
+ return tokens;
+ },
+ isManualOrdering() {
+ return this.sortKey === RELATIVE_POSITION_ASC;
+ },
+ },
+ watch: {
+ $route(newValue, oldValue) {
+ if (newValue.fullPath !== oldValue.fullPath) {
+ this.updateData(getParameterByName(PARAM_SORT));
+ }
+ },
+ },
+ created() {
+ this.updateData(this.initialSort);
+ this.cache = {};
+ },
+ methods: {
+ fetchWithCache(path, cacheName, searchKey, search) {
+ if (this.cache[cacheName]) {
+ const data = search
+ ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey })
+ : this.cache[cacheName].slice(0, MAX_LIST_SIZE);
+ return Promise.resolve(data);
+ }
+
+ return axios.get(path).then(({ data }) => {
+ this.cache[cacheName] = data;
+ return data.slice(0, MAX_LIST_SIZE);
+ });
+ },
+ fetchUsers(search) {
+ return this.$apollo
+ .query({
+ query: searchProjectMembers,
+ variables: { fullPath: this.fullPath, search },
+ })
+ .then(({ data }) =>
+ data[WORKSPACE_PROJECT]?.[`${WORKSPACE_PROJECT}Members`].nodes.map(
+ (member) => member.user,
+ ),
+ );
+ },
+ fetchMilestones(search) {
+ return this.$apollo
+ .query({
+ query: searchProjectMilestonesQuery,
+ variables: { fullPath: this.fullPath, search },
+ })
+ .then(({ data }) => data[WORKSPACE_PROJECT]?.milestones.nodes);
+ },
+ fetchEmojis(search) {
+ return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search);
+ },
+ fetchReleases(search) {
+ return this.fetchWithCache(this.releasesPath, 'releases', 'tag', search);
+ },
+ fetchLabelsWithFetchPolicy(search, fetchPolicy = fetchPolicies.CACHE_FIRST) {
+ return this.$apollo
+ .query({
+ query: searchProjectLabelsQuery,
+ variables: { fullPath: this.fullPath, search },
+ fetchPolicy,
+ })
+ .then(({ data }) => data[WORKSPACE_PROJECT]?.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())),
+ );
+ },
+ fetchLabels(search) {
+ return this.fetchLabelsWithFetchPolicy(search);
+ },
+ fetchLatestLabels(search) {
+ return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY);
+ },
+ handleClickTab(state) {
+ if (this.state === state) {
+ return;
+ }
+ this.state = state;
+ this.pageParams = getInitialPageParams(this.pageSize);
+
+ this.$router.push({ query: this.urlParams });
+ },
+ handleFilter(tokens) {
+ this.filterTokens = tokens;
+ this.pageParams = getInitialPageParams(this.pageSize);
+
+ this.$router.push({ query: this.urlParams });
+ },
+ handleDismissAlert() {
+ this.issuesError = '';
+ },
+ handleNextPage() {
+ this.pageParams = {
+ afterCursor: this.pageInfo.endCursor,
+ firstPageSize: this.pageSize,
+ };
+ scrollUp();
+
+ this.$router.push({ query: this.urlParams });
+ },
+ handlePreviousPage() {
+ this.pageParams = {
+ beforeCursor: this.pageInfo.startCursor,
+ lastPageSize: this.pageSize,
+ };
+ scrollUp();
+
+ this.$router.push({ query: this.urlParams });
+ },
+ handlePageSizeChange(newPageSize) {
+ const pageParam = getParameterByName(PARAM_LAST_PAGE_SIZE) ? 'lastPageSize' : 'firstPageSize';
+ this.pageParams[pageParam] = newPageSize;
+ this.pageSize = newPageSize;
+ scrollUp();
+
+ this.$router.push({ query: this.urlParams });
+ },
+ handleSort(sortKey) {
+ if (this.sortKey === sortKey) {
+ return;
+ }
+
+ if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) {
+ this.showIssueRepositioningMessage();
+ return;
+ }
+
+ this.sortKey = sortKey;
+ this.pageParams = getInitialPageParams(this.pageSize);
+
+ if (this.isSignedIn) {
+ this.saveSortPreference(sortKey);
+ }
+
+ this.$router.push({ query: this.urlParams });
+ },
+ saveSortPreference(sortKey) {
+ this.$apollo
+ .mutate({
+ mutation: setSortingPreferenceMutation,
+ variables: { input: { issuesSort: sortKey } },
+ })
+ .then(({ data }) => {
+ if (data.userPreferencesUpdate.errors.length) {
+ throw new Error(data.userPreferencesUpdate.errors);
+ }
+ })
+ .catch((error) => {
+ Sentry.captureException(error);
+ });
+ },
+ handleReorder({ newIndex, oldIndex }) {
+ const issueToMove = this.serviceDeskIssues[oldIndex];
+ const isDragDropDownwards = newIndex > oldIndex;
+ const isMovingToBeginning = newIndex === 0;
+ const isMovingToEnd = newIndex === this.serviceDeskIssues.length - 1;
+
+ let moveBeforeId;
+ let moveAfterId;
+
+ if (isDragDropDownwards) {
+ const afterIndex = isMovingToEnd ? newIndex : newIndex + 1;
+ moveBeforeId = this.serviceDeskIssues[newIndex].id;
+ moveAfterId = this.serviceDeskIssues[afterIndex].id;
+ } else {
+ const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1;
+ moveBeforeId = this.serviceDeskIssues[beforeIndex].id;
+ moveAfterId = this.serviceDeskIssues[newIndex].id;
+ }
+
+ return axios
+ .put(joinPaths(issueToMove.webPath, 'reorder'), {
+ move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId),
+ move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId),
+ })
+ .then(() => {
+ const serializedVariables = JSON.stringify(this.queryVariables);
+ return this.$apollo.mutate({
+ mutation: reorderServiceDeskIssuesMutation,
+ variables: { oldIndex, newIndex, namespace: this.namespace, serializedVariables },
+ });
+ })
+ .catch((error) => {
+ this.issuesError = this.$options.i18n.reorderError;
+ Sentry.captureException(error);
+ });
+ },
+ updateData(sortValue) {
+ const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE);
+ const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
+ const state = getParameterByName(PARAM_STATE);
+
+ const defaultSortKey = state === STATUS_CLOSED ? UPDATED_DESC : CREATED_DESC;
+ const dashboardSortKey = getSortKey(sortValue);
+ const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase();
+
+ let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey;
+
+ if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) {
+ this.showIssueRepositioningMessage();
+ sortKey = defaultSortKey;
+ }
+
+ this.filterTokens = getFilterTokens(window.location.search);
+
+ this.pageParams = getInitialPageParams(
+ this.pageSize,
+ isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined,
+ isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined,
+ getParameterByName(PARAM_PAGE_AFTER),
+ getParameterByName(PARAM_PAGE_BEFORE),
+ );
+ this.sortKey = sortKey;
+ this.state = state || STATUS_OPEN;
+ },
+ showIssueRepositioningMessage() {
+ createAlert({
+ message: this.$options.i18n.issueRepositioningMessage,
+ variant: VARIANT_INFO,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <info-banner v-if="isInfoBannerVisible" />
+ <issuable-list
+ v-if="canShowIssuesList"
+ namespace="service-desk"
+ recent-searches-storage-key="service-desk-issues"
+ :error="issuesError"
+ :search-tokens="searchTokens"
+ :issuables-loading="isLoading"
+ :initial-filter-value="filterTokens"
+ :show-filtered-search-friendly-text="hasOrFeature"
+ :show-pagination-controls="showPaginationControls"
+ :show-page-size-change-controls="showPageSizeControls"
+ :sort-options="sortOptions"
+ :initial-sort-by="sortKey"
+ :is-manual-ordering="isManualOrdering"
+ :issuables="serviceDeskIssues"
+ :tabs="$options.issuableListTabs"
+ :tab-counts="tabCounts"
+ :current-tab="state"
+ :default-page-size="pageSize"
+ :has-next-page="pageInfo.hasNextPage"
+ :has-previous-page="pageInfo.hasPreviousPage"
+ sync-filter-and-sort
+ use-keyset-pagination
+ @click-tab="handleClickTab"
+ @dismiss-alert="handleDismissAlert"
+ @filter="handleFilter"
+ @sort="handleSort"
+ @reorder="handleReorder"
+ @next-page="handleNextPage"
+ @previous-page="handlePreviousPage"
+ @page-size-change="handlePageSizeChange"
+ >
+ <template #empty-state>
+ <empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" />
+ </template>
+ </issuable-list>
+
+ <empty-state-without-any-issues v-else />
+ </section>
+</template>
diff --git a/app/assets/javascripts/issues/service_desk/constants.js b/app/assets/javascripts/issues/service_desk/constants.js
new file mode 100644
index 00000000000..e498a4f39a1
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/constants.js
@@ -0,0 +1,254 @@
+import { __, s__ } from '~/locale';
+import {
+ FILTERED_SEARCH_TERM,
+ OPERATOR_IS,
+ OPERATOR_NOT,
+ OPERATOR_OR,
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_CONFIDENTIAL,
+ TOKEN_TYPE_EPIC,
+ TOKEN_TYPE_HEALTH,
+ TOKEN_TYPE_ITERATION,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_RELEASE,
+ TOKEN_TYPE_TYPE,
+ TOKEN_TYPE_WEIGHT,
+ TOKEN_TYPE_SEARCH_WITHIN,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ ALTERNATIVE_FILTER,
+ API_PARAM,
+ NORMAL_FILTER,
+ SPECIAL_FILTER,
+ URL_PARAM,
+} from '~/issues/list/constants';
+
+export const SERVICE_DESK_BOT_USERNAME = 'support-bot';
+export const ISSUE_REFERENCE = /^#\d+$/;
+
+export const STATUS_ALL = 'all';
+export const STATUS_CLOSED = 'closed';
+export const STATUS_OPEN = 'opened';
+
+export const WORKSPACE_PROJECT = 'project';
+
+export const filtersMap = {
+ [FILTERED_SEARCH_TERM]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'search',
+ },
+ [URL_PARAM]: {
+ [undefined]: {
+ [NORMAL_FILTER]: 'search',
+ },
+ },
+ },
+ [TOKEN_TYPE_SEARCH_WITHIN]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'in',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'in',
+ },
+ },
+ },
+ [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_NOT]: {
+ [NORMAL_FILTER]: 'not[assignee_username][]',
+ },
+ [OPERATOR_OR]: {
+ [NORMAL_FILTER]: 'or[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_NOT]: {
+ [NORMAL_FILTER]: 'not[milestone_title]',
+ [SPECIAL_FILTER]: 'not[milestone_title]',
+ },
+ },
+ },
+ [TOKEN_TYPE_LABEL]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'labelName',
+ [SPECIAL_FILTER]: 'labelName',
+ [ALTERNATIVE_FILTER]: 'labelNames',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'label_name[]',
+ [SPECIAL_FILTER]: 'label_name[]',
+ [ALTERNATIVE_FILTER]: 'label_name',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[label_name][]',
+ },
+ [OPERATOR_OR]: {
+ [ALTERNATIVE_FILTER]: 'or[label_name][]',
+ },
+ },
+ },
+ [TOKEN_TYPE_TYPE]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'types',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'type[]',
+ },
+ [OPERATOR_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_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_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_NOT]: {
+ [NORMAL_FILTER]: 'not[iteration_id]',
+ [SPECIAL_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_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_NOT]: {
+ [NORMAL_FILTER]: 'not[weight]',
+ },
+ },
+ },
+ [TOKEN_TYPE_HEALTH]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'healthStatusFilter',
+ [SPECIAL_FILTER]: 'healthStatusFilter',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'health_status',
+ [SPECIAL_FILTER]: 'health_status',
+ },
+ [OPERATOR_NOT]: {
+ [NORMAL_FILTER]: 'not[health_status]',
+ },
+ },
+ },
+};
+
+export const errorFetchingCounts = __('An error occurred while getting issue counts');
+export const errorFetchingIssues = __('An error occurred while loading issues');
+export const noOpenIssuesTitle = __('There are no open issues');
+export const noClosedIssuesTitle = __('There are no closed issues');
+export const noIssuesSignedOutButtonText = __('Register / Sign In');
+export const noSearchResultsDescription = __(
+ 'To widen your search, change or remove filters above',
+);
+export const noSearchResultsTitle = __('Sorry, your filter produced no results');
+export const issueRepositioningMessage = __(
+ 'Issues are being rebalanced at the moment, so manual reordering is disabled.',
+);
+export const reorderError = __('An error occurred while reordering issues.');
+export const infoBannerTitle = s__(
+ 'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab',
+);
+export const infoBannerAdminNote = s__('ServiceDesk|Your users can send emails to this address:');
+export const infoBannerUserNote = s__(
+ 'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.',
+);
+export const enableServiceDesk = s__('ServiceDesk|Enable Service Desk');
+export const learnMore = __('Learn more about Service Desk');
+export const titles = __('Titles');
+export const descriptions = __('Descriptions');
+export const no = __('No');
+export const yes = __('Yes');
diff --git a/app/assets/javascripts/issues/service_desk/graphql.js b/app/assets/javascripts/issues/service_desk/graphql.js
new file mode 100644
index 00000000000..e01973f1e8a
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/graphql.js
@@ -0,0 +1,24 @@
+import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql';
+
+let client;
+
+const typePolicies = {
+ Project: {
+ fields: {
+ issues: {
+ merge: true,
+ },
+ },
+ },
+};
+
+export async function gqlClient() {
+ if (client) return client;
+ client = gon.features?.frontendCaching
+ ? await createApolloClientWithCaching(
+ {},
+ { localCacheKey: 'service_desk_list', cacheConfig: { typePolicies } },
+ )
+ : createDefaultClient({}, { cacheConfig: { typePolicies } });
+ return client;
+}
diff --git a/app/assets/javascripts/issues/service_desk/index.js b/app/assets/javascripts/issues/service_desk/index.js
new file mode 100644
index 00000000000..579cf343477
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/index.js
@@ -0,0 +1,82 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import ServiceDeskListApp from 'ee_else_ce/issues/service_desk/components/service_desk_list_app.vue';
+import { gqlClient } from './graphql';
+
+export async function mountServiceDeskListApp() {
+ const el = document.querySelector('.js-service-desk-list');
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ projectDataReleasesPath,
+ projectDataAutocompleteAwardEmojisPath,
+ projectDataHasBlockedIssuesFeature,
+ projectDataHasIterationsFeature,
+ projectDataHasIssueWeightsFeature,
+ projectDataHasIssuableHealthStatusFeature,
+ projectDataGroupPath,
+ projectDataEmptyStateSvgPath,
+ projectDataFullPath,
+ projectDataIsProject,
+ projectDataIsSignedIn,
+ projectDataSignInPath,
+ projectDataHasAnyIssues,
+ projectDataInitialSort,
+ projectDataIsIssueRepositioningDisabled,
+ serviceDeskEmailAddress,
+ canAdminIssues,
+ canEditProjectSettings,
+ serviceDeskCalloutSvgPath,
+ serviceDeskSettingsPath,
+ serviceDeskHelpPath,
+ isServiceDeskSupported,
+ isServiceDeskEnabled,
+ } = el.dataset;
+
+ Vue.use(VueApollo);
+ Vue.use(VueRouter);
+
+ return new Vue({
+ el,
+ name: 'ServiceDeskListRoot',
+ apolloProvider: new VueApollo({
+ defaultClient: await gqlClient(),
+ }),
+ router: new VueRouter({
+ base: window.location.pathname,
+ mode: 'history',
+ routes: [{ path: '/' }],
+ }),
+ provide: {
+ releasesPath: projectDataReleasesPath,
+ autocompleteAwardEmojisPath: projectDataAutocompleteAwardEmojisPath,
+ hasBlockedIssuesFeature: parseBoolean(projectDataHasBlockedIssuesFeature),
+ hasIterationsFeature: parseBoolean(projectDataHasIterationsFeature),
+ hasIssueWeightsFeature: parseBoolean(projectDataHasIssueWeightsFeature),
+ hasIssuableHealthStatusFeature: parseBoolean(projectDataHasIssuableHealthStatusFeature),
+ groupPath: projectDataGroupPath,
+ emptyStateSvgPath: projectDataEmptyStateSvgPath,
+ fullPath: projectDataFullPath,
+ isProject: parseBoolean(projectDataIsProject),
+ isSignedIn: parseBoolean(projectDataIsSignedIn),
+ serviceDeskEmailAddress,
+ canAdminIssues: parseBoolean(canAdminIssues),
+ canEditProjectSettings: parseBoolean(canEditProjectSettings),
+ serviceDeskCalloutSvgPath,
+ serviceDeskSettingsPath,
+ serviceDeskHelpPath,
+ isServiceDeskSupported: parseBoolean(isServiceDeskSupported),
+ isServiceDeskEnabled: parseBoolean(isServiceDeskEnabled),
+ signInPath: projectDataSignInPath,
+ hasAnyIssues: parseBoolean(projectDataHasAnyIssues),
+ initialSort: projectDataInitialSort,
+ isIssueRepositioningDisabled: parseBoolean(projectDataIsIssueRepositioningDisabled),
+ },
+ render: (createComponent) => createComponent(ServiceDeskListApp),
+ });
+}
diff --git a/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues.query.graphql b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues.query.graphql
new file mode 100644
index 00000000000..d8cd28f5cf1
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues.query.graphql
@@ -0,0 +1,67 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+#import "./issue.fragment.graphql"
+
+query getServiceDeskIssues(
+ $hideUsers: Boolean = false
+ $isSignedIn: Boolean = false
+ $fullPath: ID!
+ $iid: String
+ $search: String
+ $sort: IssueSort
+ $state: IssuableState
+ $in: [IssuableSearchableField!]
+ $assigneeId: String
+ $assigneeUsernames: [String!]
+ $authorUsername: String
+ $confidential: Boolean
+ $labelName: [String]
+ $milestoneTitle: [String]
+ $milestoneWildcardId: MilestoneWildcardId
+ $myReactionEmoji: String
+ $releaseTag: [String!]
+ $releaseTagWildcardId: ReleaseTagWildcardId
+ $types: [IssueType!]
+ $not: NegatedIssueFilterInput
+ $or: UnionedIssueFilterInput
+ $beforeCursor: String
+ $afterCursor: String
+ $firstPageSize: Int
+ $lastPageSize: Int
+) {
+ project(fullPath: $fullPath) @persist {
+ id
+ issues(
+ iid: $iid
+ search: $search
+ sort: $sort
+ state: $state
+ in: $in
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ confidential: $confidential
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ releaseTag: $releaseTag
+ releaseTagWildcardId: $releaseTagWildcardId
+ types: $types
+ not: $not
+ or: $or
+ before: $beforeCursor
+ after: $afterCursor
+ first: $firstPageSize
+ last: $lastPageSize
+ ) {
+ __persist
+ pageInfo {
+ ...PageInfo
+ }
+ nodes {
+ __persist
+ ...IssueFragment
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql
new file mode 100644
index 00000000000..008cde60b74
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql
@@ -0,0 +1,82 @@
+query getServiceDeskIssuesCount(
+ $fullPath: ID!
+ $iid: String
+ $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
+ $or: UnionedIssueFilterInput
+) {
+ project(fullPath: $fullPath) {
+ id
+ openedIssues: issues(
+ state: opened
+ iid: $iid
+ 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
+ or: $or
+ ) {
+ count
+ }
+ closedIssues: issues(
+ state: closed
+ iid: $iid
+ 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
+ or: $or
+ ) {
+ count
+ }
+ allIssues: issues(
+ state: all
+ iid: $iid
+ 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
+ or: $or
+ ) {
+ count
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues/service_desk/queries/issue.fragment.graphql b/app/assets/javascripts/issues/service_desk/queries/issue.fragment.graphql
new file mode 100644
index 00000000000..f72663ae5f6
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/queries/issue.fragment.graphql
@@ -0,0 +1,61 @@
+fragment IssueFragment on Issue {
+ id
+ iid
+ confidential
+ createdAt
+ downvotes
+ dueDate
+ hidden
+ humanTimeEstimate
+ mergeRequestsCount
+ moved
+ state
+ title
+ updatedAt
+ closedAt
+ upvotes
+ userDiscussionsCount @include(if: $isSignedIn)
+ webPath
+ webUrl
+ type
+ assignees @skip(if: $hideUsers) {
+ nodes {
+ __persist
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ }
+ author @skip(if: $hideUsers) {
+ __persist
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ externalAuthor
+ labels {
+ nodes {
+ __persist
+ id
+ color
+ title
+ description
+ }
+ }
+ milestone {
+ __persist
+ id
+ dueDate
+ startDate
+ webPath
+ title
+ }
+ taskCompletionStatus {
+ completedCount
+ count
+ }
+}
diff --git a/app/assets/javascripts/issues/service_desk/queries/label.fragment.graphql b/app/assets/javascripts/issues/service_desk/queries/label.fragment.graphql
new file mode 100644
index 00000000000..bb1d8f1ac9b
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/queries/label.fragment.graphql
@@ -0,0 +1,6 @@
+fragment Label on Label {
+ id
+ color
+ textColor
+ title
+}
diff --git a/app/assets/javascripts/issues/service_desk/queries/milestone.fragment.graphql b/app/assets/javascripts/issues/service_desk/queries/milestone.fragment.graphql
new file mode 100644
index 00000000000..3cdf69bf585
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/queries/milestone.fragment.graphql
@@ -0,0 +1,4 @@
+fragment Milestone on Milestone {
+ id
+ title
+}
diff --git a/app/assets/javascripts/issues/service_desk/queries/reorder_service_desk_issues.mutation.graphql b/app/assets/javascripts/issues/service_desk/queries/reorder_service_desk_issues.mutation.graphql
new file mode 100644
index 00000000000..2da7850d77d
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/queries/reorder_service_desk_issues.mutation.graphql
@@ -0,0 +1,13 @@
+mutation reorderServiceDeskIssues(
+ $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/service_desk/queries/search_project_labels.query.graphql b/app/assets/javascripts/issues/service_desk/queries/search_project_labels.query.graphql
new file mode 100644
index 00000000000..89ce14134b4
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/queries/search_project_labels.query.graphql
@@ -0,0 +1,14 @@
+#import "./label.fragment.graphql"
+
+query searchProjectLabels($fullPath: ID!, $search: String) {
+ project(fullPath: $fullPath) @persist {
+ id
+ labels(searchTerm: $search, includeAncestorGroups: true) {
+ __persist
+ nodes {
+ __persist
+ ...Label
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues/service_desk/queries/search_project_milestones.query.graphql b/app/assets/javascripts/issues/service_desk/queries/search_project_milestones.query.graphql
new file mode 100644
index 00000000000..f34166be87d
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/queries/search_project_milestones.query.graphql
@@ -0,0 +1,17 @@
+#import "./milestone.fragment.graphql"
+
+query searchProjectMilestones($fullPath: ID!, $search: String) {
+ project(fullPath: $fullPath) {
+ id
+ milestones(
+ searchTitle: $search
+ includeAncestors: true
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ state: active
+ ) {
+ nodes {
+ ...Milestone
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues/service_desk/queries/set_sorting_preference.mutation.graphql b/app/assets/javascripts/issues/service_desk/queries/set_sorting_preference.mutation.graphql
new file mode 100644
index 00000000000..b01ae3863cd
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/queries/set_sorting_preference.mutation.graphql
@@ -0,0 +1,5 @@
+mutation setSortingPreference($input: UserPreferencesUpdateInput!) {
+ userPreferencesUpdate(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues/service_desk/search_tokens.js b/app/assets/javascripts/issues/service_desk/search_tokens.js
new file mode 100644
index 00000000000..72750f518e4
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/search_tokens.js
@@ -0,0 +1,97 @@
+import { GlFilteredSearchToken } from '@gitlab/ui';
+import {
+ OPERATORS_IS,
+ TOKEN_TITLE_ASSIGNEE,
+ TOKEN_TITLE_CONFIDENTIAL,
+ TOKEN_TITLE_LABEL,
+ TOKEN_TITLE_MILESTONE,
+ TOKEN_TITLE_MY_REACTION,
+ TOKEN_TITLE_RELEASE,
+ TOKEN_TITLE_SEARCH_WITHIN,
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_CONFIDENTIAL,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_RELEASE,
+ TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_SEARCH_WITHIN,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import { titles, descriptions, yes, no } from './constants';
+
+const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_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 const searchWithinTokenBase = {
+ type: TOKEN_TYPE_SEARCH_WITHIN,
+ title: TOKEN_TITLE_SEARCH_WITHIN,
+ icon: 'search',
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATORS_IS,
+ options: [
+ { icon: 'title', value: 'TITLE', title: titles },
+ {
+ icon: 'text-description',
+ value: 'DESCRIPTION',
+ title: descriptions,
+ },
+ ],
+};
+
+export const assigneeTokenBase = {
+ type: TOKEN_TYPE_ASSIGNEE,
+ title: TOKEN_TITLE_ASSIGNEE,
+ icon: 'user',
+ token: UserToken,
+ dataType: 'user',
+};
+
+export const milestoneTokenBase = {
+ type: TOKEN_TYPE_MILESTONE,
+ title: TOKEN_TITLE_MILESTONE,
+ icon: 'clock',
+ token: MilestoneToken,
+ shouldSkipSort: true,
+};
+
+export const labelTokenBase = {
+ type: TOKEN_TYPE_LABEL,
+ title: TOKEN_TITLE_LABEL,
+ icon: 'labels',
+ token: LabelToken,
+};
+
+export const releaseTokenBase = {
+ type: TOKEN_TYPE_RELEASE,
+ title: TOKEN_TITLE_RELEASE,
+ icon: 'rocket',
+ token: ReleaseToken,
+};
+
+export const reactionTokenBase = {
+ type: TOKEN_TYPE_MY_REACTION,
+ title: TOKEN_TITLE_MY_REACTION,
+ icon: 'thumb-up',
+ token: EmojiToken,
+ unique: true,
+};
+
+export const confidentialityTokenBase = {
+ type: TOKEN_TYPE_CONFIDENTIAL,
+ title: TOKEN_TITLE_CONFIDENTIAL,
+ icon: 'eye-slash',
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATORS_IS,
+ options: [
+ { icon: 'eye-slash', value: 'yes', title: yes },
+ { icon: 'eye', value: 'no', title: no },
+ ],
+};
diff --git a/app/assets/javascripts/issues/service_desk/utils.js b/app/assets/javascripts/issues/service_desk/utils.js
new file mode 100644
index 00000000000..86f76da3880
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/utils.js
@@ -0,0 +1,37 @@
+import {
+ OPERATOR_OR,
+ TOKEN_TYPE_LABEL,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import { isSpecialFilter, isNotEmptySearchToken } from '~/issues/list/utils';
+import {
+ ALTERNATIVE_FILTER,
+ NORMAL_FILTER,
+ SPECIAL_FILTER,
+ URL_PARAM,
+} from '~/issues/list/constants';
+import { filtersMap } from './constants';
+
+const getFilterType = ({ type, value: { data, operator } }) => {
+ const isUnionedLabel = type === TOKEN_TYPE_LABEL && operator === OPERATOR_OR;
+
+ if (isUnionedLabel) {
+ return ALTERNATIVE_FILTER;
+ }
+ if (isSpecialFilter(type, data)) {
+ return SPECIAL_FILTER;
+ }
+ return NORMAL_FILTER;
+};
+
+export const convertToUrlParams = (filterTokens) => {
+ const urlParamsMap = filterTokens.filter(isNotEmptySearchToken).reduce((acc, token) => {
+ const filterType = getFilterType(token);
+ const urlParam = filtersMap[token.type][URL_PARAM][token.value.operator]?.[filterType];
+ return acc.set(
+ urlParam,
+ acc.has(urlParam) ? [acc.get(urlParam), token.value.data].flat() : token.value.data,
+ );
+ }, new Map());
+
+ return Object.fromEntries(urlParamsMap);
+};
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index 26c3db647a3..d59692d2a28 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -1,49 +1,36 @@
<script>
-import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
import { createAlert } from '~/alert';
-import {
- issuableStatusText,
- STATUS_CLOSED,
- TYPE_EPIC,
- TYPE_INCIDENT,
- TYPE_ISSUE,
- WORKSPACE_PROJECT,
-} from '~/issues/constants';
+import { TYPE_EPIC, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
+import updateDescription from '~/issues/show/utils/update_description';
+import { sanitize } from '~/lib/dompurify';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
+import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
-import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
-import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, POLLING_DELAY } from '../constants';
import eventHub from '../event_hub';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
import Service from '../services/index';
-import Store from '../stores';
import DescriptionComponent from './description.vue';
import EditedComponent from './edited.vue';
import FormComponent from './form.vue';
import HeaderActions from './header_actions.vue';
import IssueHeader from './issue_header.vue';
import PinnedLinks from './pinned_links.vue';
+import StickyHeader from './sticky_header.vue';
import TitleComponent from './title.vue';
export default {
- WORKSPACE_PROJECT,
components: {
- GlIcon,
- GlBadge,
- GlIntersectionObserver,
HeaderActions,
IssueHeader,
TitleComponent,
EditedComponent,
FormComponent,
PinnedLinks,
- ConfidentialityBadge,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ StickyHeader,
},
props: {
author: {
@@ -234,21 +221,26 @@ export default {
},
},
data() {
- const store = new Store({
- titleHtml: this.initialTitleHtml,
- titleText: this.initialTitleText,
- descriptionHtml: this.initialDescriptionHtml,
- descriptionText: this.initialDescriptionText,
- updatedAt: this.updatedAt,
- updatedByName: this.updatedByName,
- updatedByPath: this.updatedByPath,
- taskCompletionStatus: this.initialTaskCompletionStatus,
- lock_version: this.lockVersion,
- });
-
return {
- store,
- state: store.state,
+ formState: {
+ title: '',
+ description: '',
+ lockedWarningVisible: false,
+ updateLoading: false,
+ lock_version: 0,
+ issuableTemplates: {},
+ },
+ state: {
+ titleHtml: this.initialTitleHtml,
+ titleText: this.initialTitleText,
+ descriptionHtml: this.initialDescriptionHtml,
+ descriptionText: this.initialDescriptionText,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
+ taskCompletionStatus: this.initialTaskCompletionStatus,
+ lock_version: this.lockVersion,
+ },
showForm: false,
templatesRequested: false,
isStickyHeaderShowing: false,
@@ -264,17 +256,9 @@ export default {
headerClasses() {
return this.issuableType === TYPE_INCIDENT ? 'gl-mb-3' : 'gl-mb-6';
},
- issuableTemplates() {
- return this.store.formState.issuableTemplates;
- },
- formState() {
- return this.store.formState;
- },
issueChanged() {
const {
- store: {
- formState: { description, title },
- },
+ formState: { description, title },
initialDescriptionText,
initialTitleText,
} = this;
@@ -292,26 +276,13 @@ export default {
defaultErrorMessage() {
return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType });
},
- isClosed() {
- return this.issuableStatus === STATUS_CLOSED;
- },
+
pinnedLinkClasses() {
return this.showTitleBorder
? 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'
: '';
},
- statusIcon() {
- if (this.issuableType === TYPE_EPIC) {
- return this.isClosed ? 'epic-closed' : 'epic';
- }
- return this.isClosed ? 'issue-closed' : 'issues';
- },
- statusVariant() {
- return this.isClosed ? 'info' : 'success';
- },
- statusText() {
- return issuableStatusText[this.issuableStatus];
- },
+
shouldShowStickyHeader() {
return [TYPE_INCIDENT, TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType);
},
@@ -322,7 +293,7 @@ export default {
this.poll = new Poll({
resource: this.service,
method: 'getData',
- successCallback: (res) => this.store.updateState(res.data),
+ successCallback: (res) => this.updateState(res.data),
errorCallback(err) {
throw new Error(err);
},
@@ -360,23 +331,37 @@ export default {
}
return undefined;
},
+ updateState(data) {
+ const stateShouldUpdate =
+ this.state.titleText !== data.title_text ||
+ this.state.descriptionText !== data.description_text;
- updateStoreState() {
+ if (stateShouldUpdate) {
+ this.formState.lockedWarningVisible = true;
+ }
+
+ Object.assign(this.state, convertObjectPropsToCamelCase(data));
+ // find if there is an open details node inside of the issue description.
+ const descriptionSection = document.body.querySelector(
+ '.detail-page-description.content-block',
+ );
+ const details =
+ descriptionSection != null && descriptionSection.getElementsByTagName('details');
+
+ this.state.descriptionHtml = updateDescription(sanitize(data.description), details);
+ this.state.titleHtml = sanitize(data.title);
+ this.state.lock_version = data.lock_version;
+ },
+ refetchData() {
return this.service
.getData()
.then((res) => res.data)
- .then((data) => {
- this.store.updateState(data);
- })
- .catch(() => {
- createAlert({
- message: this.defaultErrorMessage,
- });
- });
+ .then(this.updateState)
+ .catch(() => createAlert({ message: this.defaultErrorMessage }));
},
setFormState(state) {
- this.store.setFormState(state);
+ this.formState = { ...this.formState, ...state };
},
updateFormState(templates = {}) {
@@ -416,7 +401,7 @@ export default {
this.templatesRequested = true;
this.requestTemplatesAndShowForm();
} else {
- this.updateAndShowForm(this.issuableTemplates);
+ this.updateAndShowForm(this.formState.issuableTemplates);
}
},
@@ -427,10 +412,7 @@ export default {
async updateIssuable() {
this.setFormState({ updateLoading: true });
- const {
- store: { formState },
- issueState,
- } = this;
+ const { formState, issueState } = this;
const issuablePayload = issueState.isDirty
? { ...formState, issue_type: issueState.issueType }
: formState;
@@ -464,7 +446,7 @@ export default {
visitUrl(URI);
}
})
- .then(this.updateStoreState)
+ .then(this.refetchData)
.then(() => {
eventHub.$emit('close.form');
})
@@ -518,7 +500,7 @@ export default {
this.poll.enable();
this.poll.makeDelayedRequest(POLLING_DELAY);
- this.updateStoreState();
+ this.refetchData();
},
},
};
@@ -531,7 +513,7 @@ export default {
:endpoint="endpoint"
:form-state="formState"
:initial-description-text="initialDescriptionText"
- :issuable-templates="issuableTemplates"
+ :issuable-templates="formState.issuableTemplates"
:markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath"
:project-path="projectPath"
@@ -559,61 +541,19 @@ export default {
</template>
</title-component>
- <gl-intersection-observer
+ <sticky-header
v-if="shouldShowStickyHeader"
- @appear="hideStickyHeader"
- @disappear="showStickyHeader"
- >
- <transition name="issuable-header-slide">
- <div
- v-if="isStickyHeaderShowing"
- class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3"
- data-testid="issue-sticky-header"
- >
- <div
- class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5"
- >
- <gl-badge :variant="statusVariant" class="gl-mr-2">
- <gl-icon :name="statusIcon" />
- <span class="gl-display-none gl-sm-display-block gl-ml-2">{{
- statusText
- }}</span></gl-badge
- >
- <span
- v-if="isLocked"
- v-gl-tooltip.bottom
- data-testid="locked"
- class="issuable-warning-icon"
- :title="__('This issue is locked. Only project members can comment.')"
- >
- <gl-icon name="lock" :aria-label="__('Locked')" />
- </span>
- <confidentiality-badge
- v-if="isConfidential"
- data-testid="confidential"
- :workspace-type="$options.WORKSPACE_PROJECT"
- :issuable-type="issuableType"
- />
- <span
- v-if="isHidden"
- v-gl-tooltip.bottom
- :title="__('This issue is hidden because its author has been banned')"
- data-testid="hidden"
- class="issuable-warning-icon"
- >
- <gl-icon name="spam" />
- </span>
- <a
- href="#top"
- class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-text-black-normal"
- :title="state.titleText"
- >
- {{ state.titleText }}
- </a>
- </div>
- </div>
- </transition>
- </gl-intersection-observer>
+ :is-confidential="isConfidential"
+ :is-hidden="isHidden"
+ :is-locked="isLocked"
+ :issuable-status="issuableStatus"
+ :issuable-type="issuableType"
+ :show="isStickyHeaderShowing"
+ :title="state.titleText"
+ :title-html="state.titleHtml"
+ @hide="hideStickyHeader"
+ @show="showStickyHeader"
+ />
<slot name="header">
<issue-header
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 90f01603f96..acbba216601 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -307,7 +307,8 @@ export default {
);
taskListItems?.forEach((item) => {
- const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate });
+ const provide = { canUpdate: this.canUpdate, issuableType: this.issuableType };
+ const dropdown = this.createTaskListItemActions(provide);
this.insertNextToTaskListItemText(dropdown, item);
this.addPointerEventListeners(item, '.task-list-item-actions');
this.hasTaskListItemActions = true;
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 1ade5e654e9..81e5c30a264 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -79,62 +79,25 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [trackingMixin, glFeatureFlagMixin()],
- inject: {
- canCreateIssue: {
- default: false,
- },
- canDestroyIssue: {
- default: false,
- },
- canPromoteToEpic: {
- default: false,
- },
- canReopenIssue: {
- default: false,
- },
- canReportSpam: {
- default: false,
- },
- canUpdateIssue: {
- default: false,
- },
- iid: {
- default: '',
- },
- issuableId: {
- default: '',
- },
- isIssueAuthor: {
- default: false,
- },
- issuePath: {
- default: '',
- },
- issueType: {
- default: TYPE_ISSUE,
- },
- newIssuePath: {
- default: '',
- },
- projectPath: {
- default: '',
- },
- submitAsSpamPath: {
- default: '',
- },
- reportedUserId: {
- default: '',
- },
- reportedFromUrl: {
- default: '',
- },
- issuableEmailAddress: {
- default: '',
- },
- fullPath: {
- default: '',
- },
- },
+ inject: [
+ 'canCreateIssue',
+ 'canDestroyIssue',
+ 'canPromoteToEpic',
+ 'canReopenIssue',
+ 'canReportSpam',
+ 'canUpdateIssue',
+ 'iid',
+ 'isIssueAuthor',
+ 'issuePath',
+ 'issueType',
+ 'newIssuePath',
+ 'projectPath',
+ 'submitAsSpamPath',
+ 'reportedUserId',
+ 'reportedFromUrl',
+ 'issuableEmailAddress',
+ 'fullPath',
+ ],
data() {
return {
isReportAbuseDrawerOpen: false,
@@ -256,7 +219,7 @@ export default {
mutation: updateIssueMutation,
variables: {
input: {
- iid: this.iid.toString(),
+ iid: String(this.iid),
projectPath: this.projectPath,
stateEvent: this.isClosed ? ISSUE_STATE_EVENT_REOPEN : ISSUE_STATE_EVENT_CLOSE,
},
@@ -501,7 +464,7 @@ export default {
>{{ copyMailAddressText }}</gl-dropdown-item
>
</template>
- <gl-dropdown-divider v-if="showToggleIssueStateButton || canDestroyIssue || canReportSpam" />
+ <gl-dropdown-divider v-if="canDestroyIssue || canReportSpam || !isIssueAuthor" />
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
index ac64c35bf15..ab1bb9253f4 100644
--- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
@@ -107,10 +107,7 @@ export default {
</script>
<template>
- <div
- class="create-timeline-event gl-relative gl-display-flex gl-align-items-start"
- :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }"
- >
+ <div class="create-timeline-event gl-relative gl-display-flex gl-align-items-start">
<div
v-if="hasTimelineEvents"
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-flex-shrink-0 gl-p-3 gl-z-index-1"
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index 4ec64ef838d..2909a4d2666 100644
--- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -43,7 +43,7 @@ export default {
variables() {
return {
fullPath: this.fullPath,
- iid: this.iid,
+ iid: String(this.iid),
};
},
update(data) {
diff --git a/app/assets/javascripts/issues/show/components/sticky_header.vue b/app/assets/javascripts/issues/show/components/sticky_header.vue
new file mode 100644
index 00000000000..bcf10ee92bb
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/sticky_header.vue
@@ -0,0 +1,130 @@
+<script>
+import { GlBadge, GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
+import {
+ issuableStatusText,
+ STATUS_CLOSED,
+ TYPE_EPIC,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
+
+export default {
+ WORKSPACE_PROJECT,
+ components: {
+ ConfidentialityBadge,
+ GlBadge,
+ GlIcon,
+ GlIntersectionObserver,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ props: {
+ isConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isHidden: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isLocked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ issuableStatus: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ show: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ titleHtml: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isClosed() {
+ return this.issuableStatus === STATUS_CLOSED;
+ },
+ statusIcon() {
+ if (this.issuableType === TYPE_EPIC) {
+ return this.isClosed ? 'epic-closed' : 'epic';
+ }
+ return this.isClosed ? 'issue-closed' : 'issues';
+ },
+ statusText() {
+ return issuableStatusText[this.issuableStatus];
+ },
+ statusVariant() {
+ return this.isClosed ? 'info' : 'success';
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-intersection-observer @appear="$emit('hide')" @disappear="$emit('show')">
+ <transition name="issuable-header-slide">
+ <div
+ v-if="show"
+ class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3"
+ data-testid="issue-sticky-header"
+ >
+ <div
+ class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-gap-2 gl-mx-auto gl-px-5"
+ >
+ <gl-badge :variant="statusVariant">
+ <gl-icon :name="statusIcon" />
+ <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ statusText }}</span>
+ </gl-badge>
+ <span
+ v-if="isLocked"
+ v-gl-tooltip.bottom
+ data-testid="locked"
+ class="issuable-warning-icon"
+ :title="__('This issue is locked. Only project members can comment.')"
+ >
+ <gl-icon name="lock" :aria-label="__('Locked')" />
+ </span>
+ <confidentiality-badge
+ v-if="isConfidential"
+ :issuable-type="issuableType"
+ :workspace-type="$options.WORKSPACE_PROJECT"
+ />
+ <span
+ v-if="isHidden"
+ v-gl-tooltip.bottom
+ :title="__('This issue is hidden because its author has been banned')"
+ data-testid="hidden"
+ class="issuable-warning-icon"
+ >
+ <gl-icon name="spam" />
+ </span>
+ <a
+ v-safe-html="titleHtml || title"
+ href="#top"
+ class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-text-black-normal"
+ >
+ </a>
+ </div>
+ </div>
+ </transition>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
index 64b916caddb..55e2e857050 100644
--- a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
+++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
@@ -1,5 +1,6 @@
<script>
import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
+import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
import { __, s__ } from '~/locale';
import eventHub from '../event_hub';
@@ -13,7 +14,12 @@ export default {
GlDisclosureDropdown,
GlDisclosureDropdownItem,
},
- inject: ['canUpdate'],
+ inject: ['canUpdate', 'issuableType'],
+ computed: {
+ showConvertToTaskItem() {
+ return [TYPE_INCIDENT, TYPE_ISSUE].includes(this.issuableType);
+ },
+ },
methods: {
convertToTask() {
eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos);
@@ -37,12 +43,17 @@ export default {
text-sr-only
toggle-class="task-list-item-actions gl-opacity-0 gl-p-2! "
>
- <gl-disclosure-dropdown-item class="gl-ml-2!" @action="convertToTask">
+ <gl-disclosure-dropdown-item
+ v-if="showConvertToTaskItem"
+ class="gl-ml-2!"
+ data-testid="convert"
+ @action="convertToTask"
+ >
<template #list-item>
{{ $options.i18n.convertToTask }}
</template>
</gl-disclosure-dropdown-item>
- <gl-disclosure-dropdown-item class="gl-ml-2!" @action="deleteTaskListItem">
+ <gl-disclosure-dropdown-item class="gl-ml-2!" data-testid="delete" @action="deleteTaskListItem">
<template #list-item>
<span class="gl-text-red-500!">{{ $options.i18n.delete }}</span>
</template>
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index a27f86bd9c3..b94f88f690e 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -6,13 +6,15 @@ import { apolloProvider } from '~/graphql_shared/issuable_client';
import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
+import initLinkedResources from '~/linked_resources';
import IssueApp from './components/app.vue';
-import HeaderActions from './components/header_actions.vue';
+import DescriptionComponent from './components/description.vue';
import IncidentTabs from './components/incidents/incident_tabs.vue';
import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue';
import { issueState } from './constants';
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
import createRouter from './components/incidents/router';
+import { parseIssuableData } from './utils/parse_data';
const bootstrapApollo = (state = {}) => {
return apolloProvider.clients.defaultClient.cache.writeQuery({
@@ -23,14 +25,15 @@ const bootstrapApollo = (state = {}) => {
});
};
-export function initIncidentApp(issueData = {}, store) {
+export function initIssuableApp(store) {
const el = document.getElementById('js-issuable-app');
if (!el) {
return undefined;
}
- bootstrapApollo({ ...issueState, issueType: TYPE_INCIDENT });
+ const issuableData = parseIssuableData(el);
+ const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData));
const {
authorId,
@@ -38,137 +41,72 @@ export function initIncidentApp(issueData = {}, store) {
authorUsername,
authorWebUrl,
canCreateIncident,
- canUpdate,
- canUpdateTimelineEvent,
+ fullPath,
iid,
issuableId,
+ issueType,
+ hasIterationsFeature,
+ // for issue
+ registerPath,
+ signInPath,
+ // for incident
+ canUpdate,
+ canUpdateTimelineEvent,
currentPath,
currentTab,
- projectNamespace,
- projectPath,
- projectId,
hasLinkedAlerts,
+ projectId,
slaFeatureAvailable,
uploadMetricsFeatureAvailable,
- } = issueData;
- const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData));
-
- const fullPath = `${projectNamespace}/${projectPath}`;
- const router = createRouter(currentPath, currentTab);
-
- return new Vue({
- el,
- name: 'DescriptionRoot',
- apolloProvider,
- store,
- router,
- provide: {
- issueType: TYPE_INCIDENT,
- canCreateIncident,
- canUpdateTimelineEvent,
- canUpdate,
- fullPath,
- iid,
- issuableId,
- projectId,
- hasLinkedAlerts: parseBoolean(hasLinkedAlerts),
- slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
- uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),
- contentEditorOnIssues: gon.features.contentEditorOnIssues,
- // for HeaderActions component
- canCreateIssue: parseBoolean(headerActionsData.canCreateIncident),
- canDestroyIssue: parseBoolean(headerActionsData.canDestroyIssue),
- canPromoteToEpic: parseBoolean(headerActionsData.canPromoteToEpic),
- canReopenIssue: parseBoolean(headerActionsData.canReopenIssue),
- canReportSpam: parseBoolean(headerActionsData.canReportSpam),
- canUpdateIssue: parseBoolean(headerActionsData.canUpdateIssue),
- isIssueAuthor: parseBoolean(headerActionsData.isIssueAuthor),
- issuePath: headerActionsData.issuePath,
- newIssuePath: headerActionsData.newIssuePath,
- projectPath: headerActionsData.projectPath,
- reportAbusePath: headerActionsData.reportAbusePath,
- reportedUserId: headerActionsData.reportedUserId,
- reportedFromUrl: headerActionsData.reportedFromUrl,
- submitAsSpamPath: headerActionsData.submitAsSpamPath,
- issuableEmailAddress: headerActionsData.issuableEmailAddress,
- },
- computed: {
- ...mapGetters(['getNoteableData']),
- },
- render(createElement) {
- return createElement(IssueApp, {
- props: {
- ...issueData,
- author: {
- id: authorId,
- name: authorName,
- username: authorUsername,
- webUrl: authorWebUrl,
- },
- issueId: Number(issuableId),
- issuableStatus: this.getNoteableData?.state,
- issuableType: TYPE_INCIDENT,
- descriptionComponent: IncidentTabs,
- showTitleBorder: false,
- isConfidential: this.getNoteableData?.confidential,
- },
- });
- },
- });
-}
-
-export function initIssueApp(issueData, store) {
- const el = document.getElementById('js-issuable-app');
+ } = issuableData;
- if (!el) {
- return undefined;
- }
+ const issueProvideData = { registerPath, signInPath };
+ const incidentProvideData = {
+ canUpdate,
+ canUpdateTimelineEvent,
+ hasLinkedAlerts: parseBoolean(hasLinkedAlerts),
+ projectId,
+ slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
+ uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),
+ };
- const { fullPath, registerPath, signInPath } = el.dataset;
- const headerActionsData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.headerActionsData));
+ bootstrapApollo({ ...issueState, issueType });
scrollToTargetOnResize();
- bootstrapApollo({ ...issueState, issueType: TYPE_ISSUE });
-
- const {
- authorId,
- authorName,
- authorUsername,
- authorWebUrl,
- canCreateIncident,
- hasIssueWeightsFeature,
- hasIterationsFeature,
- ...issueProps
- } = issueData;
+ if (issueType === TYPE_INCIDENT) {
+ initLinkedResources();
+ }
return new Vue({
el,
name: 'DescriptionRoot',
apolloProvider,
store,
+ router: issueType === TYPE_INCIDENT ? createRouter(currentPath, currentTab) : undefined,
provide: {
canCreateIncident,
fullPath,
- registerPath,
- signInPath,
- hasIssueWeightsFeature,
+ iid,
+ issuableId,
+ issueType,
hasIterationsFeature,
+ ...(issueType === TYPE_ISSUE && issueProvideData),
+ ...(issueType === TYPE_INCIDENT && incidentProvideData),
// for HeaderActions component
- canCreateIssue: parseBoolean(headerActionsData.canCreateIssue),
+ canCreateIssue:
+ issueType === TYPE_INCIDENT
+ ? parseBoolean(headerActionsData.canCreateIncident)
+ : parseBoolean(headerActionsData.canCreateIssue),
canDestroyIssue: parseBoolean(headerActionsData.canDestroyIssue),
canPromoteToEpic: parseBoolean(headerActionsData.canPromoteToEpic),
canReopenIssue: parseBoolean(headerActionsData.canReopenIssue),
canReportSpam: parseBoolean(headerActionsData.canReportSpam),
canUpdateIssue: parseBoolean(headerActionsData.canUpdateIssue),
- iid: headerActionsData.iid,
- issuableId: headerActionsData.issuableId,
isIssueAuthor: parseBoolean(headerActionsData.isIssueAuthor),
issuePath: headerActionsData.issuePath,
- issueType: headerActionsData.issueType,
newIssuePath: headerActionsData.newIssuePath,
projectPath: headerActionsData.projectPath,
- projectId: headerActionsData.projectId,
reportAbusePath: headerActionsData.reportAbusePath,
reportedUserId: headerActionsData.reportedUserId,
reportedFromUrl: headerActionsData.reportedFromUrl,
@@ -181,67 +119,27 @@ export function initIssueApp(issueData, store) {
render(createElement) {
return createElement(IssueApp, {
props: {
- ...issueProps,
+ ...issuableData,
author: {
id: authorId,
name: authorName,
username: authorUsername,
webUrl: authorWebUrl,
},
+ descriptionComponent: issueType === TYPE_INCIDENT ? IncidentTabs : DescriptionComponent,
isConfidential: this.getNoteableData?.confidential,
isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
+ issuableType: issueType,
issueId: this.getNoteableData?.id,
issueIid: this.getNoteableData?.iid,
+ showTitleBorder: issueType !== TYPE_INCIDENT,
},
});
},
});
}
-export function initHeaderActions(store, type = '') {
- const el = document.querySelector('.js-issue-header-actions');
-
- if (!el) {
- return undefined;
- }
-
- bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
-
- const canCreate =
- type === TYPE_INCIDENT ? el.dataset.canCreateIncident : el.dataset.canCreateIssue;
-
- return new Vue({
- el,
- name: 'HeaderActionsRoot',
- apolloProvider,
- store,
- provide: {
- canCreateIssue: parseBoolean(canCreate),
- 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,
- issuableId: el.dataset.issuableId,
- 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,
- reportedUserId: parseInt(el.dataset.reportedUserId, 10),
- reportedFromUrl: el.dataset.reportedFromUrl,
- submitAsSpamPath: el.dataset.submitAsSpamPath,
- issuableEmailAddress: el.dataset.issuableEmailAddress,
- fullPath: el.dataset.projectPath,
- },
- render: (createElement) => createElement(HeaderActions),
- });
-}
-
export function initSentryErrorStackTrace() {
const el = document.querySelector('#js-sentry-error-stack-trace');
diff --git a/app/assets/javascripts/issues/show/stores/index.js b/app/assets/javascripts/issues/show/stores/index.js
deleted file mode 100644
index a50913d3455..00000000000
--- a/app/assets/javascripts/issues/show/stores/index.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import { sanitize } from '~/lib/dompurify';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import updateDescription from '../utils/update_description';
-
-export default class Store {
- constructor(initialState) {
- this.state = initialState;
- this.formState = {
- title: '',
- description: '',
- lockedWarningVisible: false,
- updateLoading: false,
- lock_version: 0,
- issuableTemplates: {},
- };
- }
-
- updateState(data) {
- if (this.stateShouldUpdate(data)) {
- this.formState.lockedWarningVisible = true;
- }
-
- Object.assign(this.state, convertObjectPropsToCamelCase(data));
- // find if there is an open details node inside of the issue description.
- const descriptionSection = document.body.querySelector(
- '.detail-page-description.content-block',
- );
- const details =
- descriptionSection != null && descriptionSection.getElementsByTagName('details');
-
- this.state.descriptionHtml = updateDescription(sanitize(data.description), details);
- this.state.titleHtml = sanitize(data.title);
- this.state.lock_version = data.lock_version;
- }
-
- stateShouldUpdate(data) {
- return (
- this.state.titleText !== data.title_text ||
- this.state.descriptionText !== data.description_text
- );
- }
-
- setFormState(state) {
- this.formState = Object.assign(this.formState, state);
- }
-}