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:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-09-07 12:11:12 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-09-07 12:11:12 +0300
commit3822e951cb0ced8b24d79949339684312c8bb2e1 (patch)
treef616601ae823c4c7ece1eb63bc056bd52a27a7b5 /app/assets/javascripts/issues
parent4bcd95583079cb769cfe8fa5350cbf3864f15f99 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/issues')
-rw-r--r--app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue58
-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
18 files changed, 1558 insertions, 0 deletions
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..a15c8ee2e9f
--- /dev/null
+++ b/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue
@@ -0,0 +1,58 @@
+<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,
+ };
+ } else 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);
+};