diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-07 12:11:12 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-07 12:11:12 +0300 |
commit | 3822e951cb0ced8b24d79949339684312c8bb2e1 (patch) | |
tree | f616601ae823c4c7ece1eb63bc056bd52a27a7b5 /app/assets/javascripts/issues | |
parent | 4bcd95583079cb769cfe8fa5350cbf3864f15f99 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/issues')
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); +}; |