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>2020-09-16 15:10:15 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-09-16 15:10:15 +0300
commit591b0e86e3dbaa31b68340a14dc32859306a27b1 (patch)
tree48f44f6a1de3fe9f38d314bf95988bc6efc527c4 /app/assets/javascripts/issues_list
parent984357420ab0a91e8c73f04393a83b5ade63b460 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/issues_list')
-rw-r--r--app/assets/javascripts/issues_list/components/issuable.vue432
-rw-r--r--app/assets/javascripts/issues_list/components/issuables_list_app.vue424
-rw-r--r--app/assets/javascripts/issues_list/components/jira_issues_list_root.vue112
-rw-r--r--app/assets/javascripts/issues_list/constants.js56
-rw-r--r--app/assets/javascripts/issues_list/eventhub.js3
-rw-r--r--app/assets/javascripts/issues_list/index.js67
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql21
-rw-r--r--app/assets/javascripts/issues_list/service_desk_helper.js111
8 files changed, 1226 insertions, 0 deletions
diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue
new file mode 100644
index 00000000000..adfb234fe7a
--- /dev/null
+++ b/app/assets/javascripts/issues_list/components/issuable.vue
@@ -0,0 +1,432 @@
+<script>
+/*
+ * This is tightly coupled to projects/issues/_issue.html.haml,
+ * any changes done to the haml need to be reflected here.
+ */
+
+// TODO: need to move this component to graphql - https://gitlab.com/gitlab-org/gitlab/-/issues/221246
+import { escape, isNumber } from 'lodash';
+import {
+ GlLink,
+ GlTooltipDirective as GlTooltip,
+ GlSprintf,
+ GlLabel,
+ GlIcon,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg';
+import {
+ dateInWords,
+ formatDate,
+ getDayDifference,
+ getTimeago,
+ timeFor,
+ newDateAsLocaleTime,
+} from '~/lib/utils/datetime_utility';
+import { sprintf, __ } from '~/locale';
+import initUserPopovers from '~/user_popovers';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+import { convertToCamelCase } from '~/lib/utils/text_utility';
+
+export default {
+ i18n: {
+ openedAgo: __('opened %{timeAgoString} by %{user}'),
+ openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'),
+ },
+ components: {
+ IssueAssignees,
+ GlLink,
+ GlLabel,
+ GlIcon,
+ GlSprintf,
+ IssueHealthStatus: () =>
+ import('ee_component/related_items_tree/components/issue_health_status.vue'),
+ },
+ directives: {
+ GlTooltip,
+ SafeHtml,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ isBulkEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ baseUrl: {
+ type: String,
+ required: false,
+ default() {
+ return window.location.href;
+ },
+ },
+ },
+ data() {
+ return {
+ jiraLogo,
+ };
+ },
+ computed: {
+ milestoneLink() {
+ const { title } = this.issuable.milestone;
+
+ return this.issuableLink({ milestone_title: title });
+ },
+ scopedLabelsAvailable() {
+ return this.glFeatures.scopedLabels;
+ },
+ hasWeight() {
+ return isNumber(this.issuable.weight);
+ },
+ dueDate() {
+ return this.issuable.due_date ? newDateAsLocaleTime(this.issuable.due_date) : undefined;
+ },
+ dueDateWords() {
+ return this.dueDate ? dateInWords(this.dueDate, true) : undefined;
+ },
+ isOverdue() {
+ return this.dueDate ? this.dueDate < new Date() : false;
+ },
+ isClosed() {
+ return this.issuable.state === 'closed';
+ },
+ isJiraIssue() {
+ return this.issuable.external_tracker === 'jira';
+ },
+ linkTarget() {
+ return this.isJiraIssue ? '_blank' : null;
+ },
+ issueCreatedToday() {
+ return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1;
+ },
+ labelIdsString() {
+ return JSON.stringify(this.issuable.labels.map(l => l.id));
+ },
+ milestoneDueDate() {
+ const { due_date: dueDate } = this.issuable.milestone || {};
+
+ return dueDate ? newDateAsLocaleTime(dueDate) : undefined;
+ },
+ milestoneTooltipText() {
+ if (this.milestoneDueDate) {
+ return sprintf(__('%{primary} (%{secondary})'), {
+ primary: formatDate(this.milestoneDueDate, 'mmm d, yyyy'),
+ secondary: timeFor(this.milestoneDueDate),
+ });
+ }
+ return __('Milestone');
+ },
+ issuableAuthor() {
+ return this.issuable.author;
+ },
+ issuableCreatedAt() {
+ return getTimeago().format(this.issuable.created_at);
+ },
+ popoverDataAttrs() {
+ const { id, username, name, avatar_url } = this.issuableAuthor;
+
+ return {
+ 'data-user-id': id,
+ 'data-username': username,
+ 'data-name': name,
+ 'data-avatar-url': avatar_url,
+ };
+ },
+ referencePath() {
+ return this.issuable.references.relative;
+ },
+ updatedDateString() {
+ return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt');
+ },
+ updatedDateAgo() {
+ // snake_case because it's the same i18n string as the HAML view
+ return sprintf(__('updated %{time_ago}'), {
+ time_ago: escape(getTimeago().format(this.issuable.updated_at)),
+ });
+ },
+ issuableMeta() {
+ return [
+ {
+ key: 'merge-requests',
+ visible: this.issuable.merge_requests_count > 0,
+ value: this.issuable.merge_requests_count,
+ title: __('Related merge requests'),
+ dataTestId: 'merge-requests',
+ class: 'js-merge-requests',
+ icon: 'merge-request',
+ },
+ {
+ key: 'upvotes',
+ visible: this.issuable.upvotes > 0,
+ value: this.issuable.upvotes,
+ title: __('Upvotes'),
+ dataTestId: 'upvotes',
+ class: 'js-upvotes issuable-upvotes',
+ icon: 'thumb-up',
+ },
+ {
+ key: 'downvotes',
+ visible: this.issuable.downvotes > 0,
+ value: this.issuable.downvotes,
+ title: __('Downvotes'),
+ dataTestId: 'downvotes',
+ class: 'js-downvotes issuable-downvotes',
+ icon: 'thumb-down',
+ },
+ {
+ key: 'blocking-issues',
+ visible: this.issuable.blocking_issues_count > 0,
+ value: this.issuable.blocking_issues_count,
+ title: __('Blocking issues'),
+ dataTestId: 'blocking-issues',
+ href: `${this.issuable.web_url}#related-issues`,
+ icon: 'issue-block',
+ },
+ {
+ key: 'comments-count',
+ visible: !this.isJiraIssue,
+ value: this.issuable.user_notes_count,
+ title: __('Comments'),
+ dataTestId: 'notes-count',
+ href: `${this.issuable.web_url}#notes`,
+ class: { 'no-comments': !this.issuable.user_notes_count, 'issuable-comments': true },
+ icon: 'comments',
+ },
+ ];
+ },
+ healthStatus() {
+ return convertToCamelCase(this.issuable.health_status);
+ },
+ },
+ mounted() {
+ // TODO: Refactor user popover to use its own component instead of
+ // spawning event listeners on Vue-rendered elements.
+ initUserPopovers([this.$refs.openedAgoByContainer.$el]);
+ },
+ methods: {
+ issuableLink(params) {
+ return mergeUrlParams(params, this.baseUrl);
+ },
+ isScoped({ name }) {
+ return isScopedLabel({ title: name }) && this.scopedLabelsAvailable;
+ },
+ labelHref({ name }) {
+ if (this.isJiraIssue) {
+ return this.issuableLink({ 'labels[]': name });
+ }
+
+ return this.issuableLink({ 'label_name[]': name });
+ },
+ onSelect(ev) {
+ this.$emit('select', {
+ issuable: this.issuable,
+ selected: ev.target.checked,
+ });
+ },
+ issuableMetaComponent(href) {
+ return href ? 'gl-link' : 'span';
+ },
+ },
+
+ confidentialTooltipText: __('Confidential'),
+};
+</script>
+<template>
+ <li
+ :id="`issue_${issuable.id}`"
+ class="issue"
+ :class="{ today: issueCreatedToday, closed: isClosed }"
+ :data-id="issuable.id"
+ :data-labels="labelIdsString"
+ :data-url="issuable.web_url"
+ data-qa-selector="issue_container"
+ :data-qa-issue-title="issuable.title"
+ >
+ <div class="gl-display-flex">
+ <!-- Bulk edit checkbox -->
+ <div v-if="isBulkEditing" class="gl-mr-3">
+ <input
+ :id="`selected_issue_${issuable.id}`"
+ :checked="selected"
+ class="selected-issuable"
+ type="checkbox"
+ :data-id="issuable.id"
+ @input="onSelect"
+ />
+ </div>
+
+ <!-- Issuable info container -->
+ <!-- Issuable main info -->
+ <div class="gl-flex-grow-1">
+ <div class="title">
+ <span class="issue-title-text">
+ <gl-icon
+ v-if="issuable.confidential"
+ v-gl-tooltip
+ name="eye-slash"
+ class="gl-vertical-align-text-bottom"
+ :size="16"
+ :title="$options.confidentialTooltipText"
+ :aria-label="$options.confidentialTooltipText"
+ />
+ <gl-link
+ :href="issuable.web_url"
+ :target="linkTarget"
+ data-testid="issuable-title"
+ data-qa-selector="issue_link"
+ >{{ issuable.title
+ }}<gl-icon
+ v-if="isJiraIssue"
+ name="external-link"
+ class="gl-vertical-align-text-bottom gl-ml-2"
+ />
+ </gl-link>
+ </span>
+ <span
+ v-if="issuable.has_tasks"
+ class="gl-ml-2 task-status gl-display-none d-sm-inline-block"
+ >{{ issuable.task_status }}</span
+ >
+ </div>
+
+ <div class="issuable-info">
+ <span class="js-ref-path gl-mr-4 mr-sm-0">
+ <span
+ v-if="isJiraIssue"
+ v-safe-html="jiraLogo"
+ class="svg-container jira-logo-container"
+ data-testid="jira-logo"
+ ></span>
+ {{ referencePath }}
+ </span>
+
+ <span data-testid="openedByMessage" class="gl-display-none d-sm-inline-block gl-mr-4">
+ &middot;
+ <gl-sprintf
+ :message="isJiraIssue ? $options.i18n.openedAgoJira : $options.i18n.openedAgo"
+ >
+ <template #timeAgoString>
+ <span>{{ issuableCreatedAt }}</span>
+ </template>
+ <template #user>
+ <gl-link
+ ref="openedAgoByContainer"
+ v-bind="popoverDataAttrs"
+ :href="issuableAuthor.web_url"
+ :target="linkTarget"
+ >{{ issuableAuthor.name }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+
+ <gl-link
+ v-if="issuable.milestone"
+ v-gl-tooltip
+ class="gl-display-none d-sm-inline-block gl-mr-4 js-milestone milestone"
+ :href="milestoneLink"
+ :title="milestoneTooltipText"
+ >
+ <gl-icon name="clock" class="s16 gl-vertical-align-text-bottom" />
+ {{ issuable.milestone.title }}
+ </gl-link>
+
+ <span
+ v-if="dueDate"
+ v-gl-tooltip
+ class="gl-display-none d-sm-inline-block gl-mr-4 js-due-date"
+ :class="{ cred: isOverdue }"
+ :title="__('Due date')"
+ >
+ <i class="fa fa-calendar"></i>
+ {{ dueDateWords }}
+ </span>
+
+ <span
+ v-if="hasWeight"
+ v-gl-tooltip
+ :title="__('Weight')"
+ class="gl-display-none d-sm-inline-block gl-mr-4"
+ data-testid="weight"
+ data-qa-selector="issuable_weight_content"
+ >
+ <gl-icon name="weight" class="align-text-bottom" />
+ {{ issuable.weight }}
+ </span>
+
+ <issue-health-status
+ v-if="issuable.health_status"
+ :health-status="healthStatus"
+ class="gl-mr-4 issuable-tag-valign"
+ />
+
+ <gl-label
+ v-for="label in issuable.labels"
+ :key="label.id"
+ data-qa-selector="issuable-label"
+ :target="labelHref(label)"
+ :background-color="label.color"
+ :description="label.description"
+ :color="label.text_color"
+ :title="label.name"
+ :scoped="isScoped(label)"
+ size="sm"
+ class="gl-mr-2 issuable-tag-valign"
+ >{{ label.name }}</gl-label
+ >
+ </div>
+ </div>
+
+ <!-- Issuable meta -->
+ <div
+ class="gl-flex-shrink-0 gl-display-flex gl-flex-direction-column align-items-end gl-justify-content-center"
+ >
+ <div class="controls gl-display-flex">
+ <span v-if="isJiraIssue" data-testid="issuable-status">{{ issuable.status }}</span>
+ <span v-else-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span>
+
+ <issue-assignees
+ :assignees="issuable.assignees"
+ class="gl-align-items-center gl-display-flex gl-ml-3"
+ :icon-size="16"
+ img-css-classes="gl-mr-2!"
+ :max-visible="4"
+ />
+
+ <template v-for="meta in issuableMeta">
+ <span
+ v-if="meta.visible"
+ :key="meta.key"
+ v-gl-tooltip
+ class="gl-display-none gl-display-sm-flex gl-align-items-center gl-ml-3"
+ :class="meta.class"
+ :data-testid="meta.dataTestId"
+ :title="meta.title"
+ >
+ <component :is="issuableMetaComponent(meta.href)" :href="meta.href">
+ <gl-icon v-if="meta.icon" :name="meta.icon" />
+ {{ meta.value }}
+ </component>
+ </span>
+ </template>
+ </div>
+ <div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString">
+ {{ updatedDateAgo }}
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
new file mode 100644
index 00000000000..0d4f5bce965
--- /dev/null
+++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
@@ -0,0 +1,424 @@
+<script>
+import { toNumber, omit } from 'lodash';
+import {
+ GlEmptyState,
+ GlPagination,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+import { deprecatedCreateFlash as flash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import {
+ scrollToElement,
+ urlParamsToObject,
+ historyPushState,
+ getParameterByName,
+} from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import initManualOrdering from '~/manual_ordering';
+import Issuable from './issuable.vue';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import {
+ sortOrderMap,
+ availableSortOptionsJira,
+ RELATIVE_POSITION,
+ PAGE_SIZE,
+ PAGE_SIZE_MANUAL,
+ LOADING_LIST_ITEMS_LENGTH,
+} from '../constants';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import issueableEventHub from '../eventhub';
+import { emptyStateHelper } from '../service_desk_helper';
+
+export default {
+ LOADING_LIST_ITEMS_LENGTH,
+ directives: {
+ SafeHtml,
+ },
+ components: {
+ GlEmptyState,
+ GlPagination,
+ GlSkeletonLoading,
+ Issuable,
+ FilteredSearchBar,
+ },
+ props: {
+ canBulkEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ emptyStateMeta: {
+ type: Object,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sortKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ type: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ availableSortOptionsJira,
+ filters: {},
+ isBulkEditing: false,
+ issuables: [],
+ loading: false,
+ page:
+ getParameterByName('page', window.location.href) !== null
+ ? toNumber(getParameterByName('page'))
+ : 1,
+ selection: {},
+ totalItems: 0,
+ };
+ },
+ computed: {
+ allIssuablesSelected() {
+ // WARNING: Because we are only keeping track of selected values
+ // this works, we will need to rethink this if we start tracking
+ // [id]: false for not selected values.
+ return this.issuables.length === Object.keys(this.selection).length;
+ },
+ emptyState() {
+ if (this.issuables.length) {
+ return {}; // Empty state shouldn't be shown here
+ }
+
+ if (this.isServiceDesk) {
+ return emptyStateHelper(this.emptyStateMeta);
+ }
+
+ if (this.hasFilters) {
+ return {
+ title: __('Sorry, your filter produced no results'),
+ svgPath: this.emptyStateMeta.svgPath,
+ description: __('To widen your search, change or remove filters above'),
+ primaryLink: this.emptyStateMeta.createIssuePath,
+ primaryText: __('New issue'),
+ };
+ }
+
+ if (this.filters.state === 'opened') {
+ return {
+ title: __('There are no open issues'),
+ svgPath: this.emptyStateMeta.svgPath,
+ description: __('To keep this project going, create a new issue'),
+ primaryLink: this.emptyStateMeta.createIssuePath,
+ primaryText: __('New issue'),
+ };
+ } else if (this.filters.state === 'closed') {
+ return {
+ title: __('There are no closed issues'),
+ svgPath: this.emptyStateMeta.svgPath,
+ };
+ }
+
+ return {
+ title: __('There are no issues to show'),
+ svgPath: this.emptyStateMeta.svgPath,
+ description: __(
+ 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
+ ),
+ };
+ },
+ hasFilters() {
+ const ignored = ['utf8', 'state', 'scope', 'order_by', 'sort'];
+ return Object.keys(omit(this.filters, ignored)).length > 0;
+ },
+ isManualOrdering() {
+ return this.sortKey === RELATIVE_POSITION;
+ },
+ itemsPerPage() {
+ return this.isManualOrdering ? PAGE_SIZE_MANUAL : PAGE_SIZE;
+ },
+ baseUrl() {
+ return window.location.href.replace(/(\?.*)?(#.*)?$/, '');
+ },
+ paginationNext() {
+ return this.page + 1;
+ },
+ paginationPrev() {
+ return this.page - 1;
+ },
+ paginationProps() {
+ const paginationProps = { value: this.page };
+
+ if (this.totalItems) {
+ return {
+ ...paginationProps,
+ perPage: this.itemsPerPage,
+ totalItems: this.totalItems,
+ };
+ }
+
+ return {
+ ...paginationProps,
+ prevPage: this.paginationPrev,
+ nextPage: this.paginationNext,
+ };
+ },
+ isServiceDesk() {
+ return this.type === 'service_desk';
+ },
+ isJira() {
+ return this.type === 'jira';
+ },
+ initialFilterValue() {
+ const value = [];
+ const { search } = this.getQueryObject();
+
+ if (search) {
+ value.push(search);
+ }
+ return value;
+ },
+ initialSortBy() {
+ const { sort } = this.getQueryObject();
+ return sort || 'created_desc';
+ },
+ },
+ watch: {
+ selection() {
+ // We need to call nextTick here to wait for all of the boxes to be checked and rendered
+ // before we query the dom in issuable_bulk_update_actions.js.
+ this.$nextTick(() => {
+ issueableEventHub.$emit('issuables:updateBulkEdit');
+ });
+ },
+ issuables() {
+ this.$nextTick(() => {
+ initManualOrdering();
+ });
+ },
+ },
+ mounted() {
+ if (this.canBulkEdit) {
+ this.unsubscribeToggleBulkEdit = issueableEventHub.$on('issuables:toggleBulkEdit', val => {
+ this.isBulkEditing = val;
+ });
+ }
+ this.fetchIssuables();
+ },
+ beforeDestroy() {
+ issueableEventHub.$off('issuables:toggleBulkEdit');
+ },
+ methods: {
+ isSelected(issuableId) {
+ return Boolean(this.selection[issuableId]);
+ },
+ setSelection(ids) {
+ ids.forEach(id => {
+ this.select(id, true);
+ });
+ },
+ clearSelection() {
+ this.selection = {};
+ },
+ select(id, isSelect = true) {
+ if (isSelect) {
+ this.$set(this.selection, id, true);
+ } else {
+ this.$delete(this.selection, id);
+ }
+ },
+ fetchIssuables(pageToFetch) {
+ this.loading = true;
+
+ this.clearSelection();
+
+ this.setFilters();
+
+ return axios
+ .get(this.endpoint, {
+ params: {
+ ...this.filters,
+
+ with_labels_details: true,
+ page: pageToFetch || this.page,
+ per_page: this.itemsPerPage,
+ },
+ })
+ .then(response => {
+ this.loading = false;
+ this.issuables = response.data;
+ this.totalItems = Number(response.headers['x-total']);
+ this.page = Number(response.headers['x-page']);
+ })
+ .catch(() => {
+ this.loading = false;
+ return flash(__('An error occurred while loading issues'));
+ });
+ },
+ getQueryObject() {
+ return urlParamsToObject(window.location.search);
+ },
+ onPaginate(newPage) {
+ if (newPage === this.page) return;
+
+ scrollToElement('#content-body');
+
+ // NOTE: This allows for the params to be updated on pagination
+ historyPushState(
+ setUrlParams({ ...this.filters, page: newPage }, window.location.href, true),
+ );
+
+ this.fetchIssuables(newPage);
+ },
+ onSelectAll() {
+ if (this.allIssuablesSelected) {
+ this.selection = {};
+ } else {
+ this.setSelection(this.issuables.map(({ id }) => id));
+ }
+ },
+ onSelectIssuable({ issuable, selected }) {
+ if (!this.canBulkEdit) return;
+
+ this.select(issuable.id, selected);
+ },
+ setFilters() {
+ const {
+ label_name: labels,
+ milestone_title: milestoneTitle,
+ 'not[label_name]': excludedLabels,
+ 'not[milestone_title]': excludedMilestone,
+ ...filters
+ } = this.getQueryObject();
+
+ // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/227880
+
+ if (milestoneTitle) {
+ filters.milestone = milestoneTitle;
+ }
+ if (Array.isArray(labels)) {
+ filters.labels = labels.join(',');
+ }
+ if (!filters.state) {
+ filters.state = 'opened';
+ }
+
+ if (excludedLabels) {
+ filters['not[labels]'] = excludedLabels;
+ }
+
+ if (excludedMilestone) {
+ filters['not[milestone]'] = excludedMilestone;
+ }
+
+ Object.assign(filters, sortOrderMap[this.sortKey]);
+
+ this.filters = filters;
+ },
+ refetchIssuables() {
+ const ignored = ['utf8'];
+ const params = omit(this.filters, ignored);
+
+ historyPushState(setUrlParams(params, window.location.href, true, true));
+ this.fetchIssuables();
+ },
+ handleFilter(filters) {
+ let search = null;
+
+ filters.forEach(filter => {
+ if (typeof filter === 'string') {
+ search = filter;
+ }
+ });
+
+ this.filters.search = search;
+ this.page = 1;
+
+ this.refetchIssuables();
+ },
+ handleSort(sort) {
+ this.filters.sort = sort;
+ this.page = 1;
+
+ this.refetchIssuables();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <filtered-search-bar
+ v-if="isJira"
+ :namespace="projectPath"
+ :search-input-placeholder="__('Search Jira issues')"
+ :tokens="[]"
+ :sort-options="availableSortOptionsJira"
+ :initial-filter-value="initialFilterValue"
+ :initial-sort-by="initialSortBy"
+ class="row-content-block"
+ @onFilter="handleFilter"
+ @onSort="handleSort"
+ />
+ <ul v-if="loading" class="content-list">
+ <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!">
+ <gl-skeleton-loading />
+ </li>
+ </ul>
+ <div v-else-if="issuables.length">
+ <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light">
+ <input
+ id="check-all-issues"
+ type="checkbox"
+ :checked="allIssuablesSelected"
+ class="mr-2"
+ @click="onSelectAll"
+ />
+ <strong>{{ __('Select all') }}</strong>
+ </div>
+ <ul
+ class="content-list issuable-list issues-list"
+ :class="{ 'manual-ordering': isManualOrdering }"
+ >
+ <issuable
+ v-for="issuable in issuables"
+ :key="issuable.id"
+ class="pr-3"
+ :class="{ 'user-can-drag': isManualOrdering }"
+ :issuable="issuable"
+ :is-bulk-editing="isBulkEditing"
+ :selected="isSelected(issuable.id)"
+ :base-url="baseUrl"
+ @select="onSelectIssuable"
+ />
+ </ul>
+ <div class="mt-3">
+ <gl-pagination
+ v-bind="paginationProps"
+ class="gl-justify-content-center"
+ @input="onPaginate"
+ />
+ </div>
+ </div>
+ <gl-empty-state
+ v-else
+ :title="emptyState.title"
+ :svg-path="emptyState.svgPath"
+ :primary-button-link="emptyState.primaryLink"
+ :primary-button-text="emptyState.primaryText"
+ >
+ <template #description>
+ <div v-safe-html="emptyState.description"></div>
+ </template>
+ </gl-empty-state>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue b/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue
new file mode 100644
index 00000000000..61781c576c0
--- /dev/null
+++ b/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlAlert, GlLabel } from '@gitlab/ui';
+import { last } from 'lodash';
+import { n__ } from '~/locale';
+import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql';
+import {
+ calculateJiraImportLabel,
+ isInProgress,
+ setFinishedAlertHideMap,
+ shouldShowFinishedAlert,
+} from '~/jira_import/utils/jira_import_utils';
+
+export default {
+ name: 'JiraIssuesList',
+ components: {
+ GlAlert,
+ GlLabel,
+ },
+ props: {
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ isJiraConfigured: {
+ type: Boolean,
+ required: true,
+ },
+ issuesPath: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ jiraImport: {},
+ };
+ },
+ apollo: {
+ jiraImport: {
+ query: getIssuesListDetailsQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ };
+ },
+ update: ({ project }) => {
+ const label = calculateJiraImportLabel(
+ project.jiraImports.nodes,
+ project.issues.nodes.flatMap(({ labels }) => labels.nodes),
+ );
+ return {
+ importedIssuesCount: last(project.jiraImports.nodes)?.importedIssuesCount,
+ label,
+ shouldShowFinishedAlert: shouldShowFinishedAlert(label.title, project.jiraImportStatus),
+ shouldShowInProgressAlert: isInProgress(project.jiraImportStatus),
+ };
+ },
+ skip() {
+ return !this.isJiraConfigured || !this.canEdit;
+ },
+ },
+ },
+ computed: {
+ finishedMessage() {
+ return n__(
+ '%d issue successfully imported with the label',
+ '%d issues successfully imported with the label',
+ this.jiraImport.importedIssuesCount,
+ );
+ },
+ labelTarget() {
+ return `${this.issuesPath}?label_name[]=${encodeURIComponent(this.jiraImport.label.title)}`;
+ },
+ },
+ methods: {
+ hideFinishedAlert() {
+ setFinishedAlertHideMap(this.jiraImport.label.title);
+ this.jiraImport.shouldShowFinishedAlert = false;
+ },
+ hideInProgressAlert() {
+ this.jiraImport.shouldShowInProgressAlert = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-list-root">
+ <gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert">
+ {{ __('Import in progress. Refresh page to see newly added issues.') }}
+ </gl-alert>
+
+ <gl-alert
+ v-if="jiraImport.shouldShowFinishedAlert"
+ variant="success"
+ @dismiss="hideFinishedAlert"
+ >
+ {{ finishedMessage }}
+ <gl-label
+ :background-color="jiraImport.label.color"
+ scoped
+ size="sm"
+ :target="labelTarget"
+ :title="jiraImport.label.title"
+ />
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
new file mode 100644
index 00000000000..f008ba1bf4a
--- /dev/null
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -0,0 +1,56 @@
+import { __ } from '~/locale';
+
+// Maps sort order as it appears in the URL query to API `order_by` and `sort` params.
+const PRIORITY = 'priority';
+const ASC = 'asc';
+const DESC = 'desc';
+const CREATED_AT = 'created_at';
+const UPDATED_AT = 'updated_at';
+const DUE_DATE = 'due_date';
+const MILESTONE_DUE = 'milestone_due';
+const POPULARITY = 'popularity';
+const WEIGHT = 'weight';
+const LABEL_PRIORITY = 'label_priority';
+export const RELATIVE_POSITION = 'relative_position';
+export const LOADING_LIST_ITEMS_LENGTH = 8;
+export const PAGE_SIZE = 20;
+export const PAGE_SIZE_MANUAL = 100;
+
+export const sortOrderMap = {
+ priority: { order_by: PRIORITY, sort: ASC }, // asc and desc are flipped for some reason
+ created_date: { order_by: CREATED_AT, sort: DESC },
+ created_asc: { order_by: CREATED_AT, sort: ASC },
+ updated_desc: { order_by: UPDATED_AT, sort: DESC },
+ updated_asc: { order_by: UPDATED_AT, sort: ASC },
+ milestone_due_desc: { order_by: MILESTONE_DUE, sort: DESC },
+ milestone: { order_by: MILESTONE_DUE, sort: ASC },
+ due_date_desc: { order_by: DUE_DATE, sort: DESC },
+ due_date: { order_by: DUE_DATE, sort: ASC },
+ popularity: { order_by: POPULARITY, sort: DESC },
+ popularity_asc: { order_by: POPULARITY, sort: ASC },
+ label_priority: { order_by: LABEL_PRIORITY, sort: ASC }, // asc and desc are flipped
+ relative_position: { order_by: RELATIVE_POSITION, sort: ASC },
+ weight_desc: { order_by: WEIGHT, sort: DESC },
+ weight: { order_by: WEIGHT, sort: ASC },
+};
+
+export const availableSortOptionsJira = [
+ {
+ id: 1,
+ title: __('Created date'),
+ sortDirection: {
+ descending: 'created_desc',
+ ascending: 'created_asc',
+ },
+ },
+ {
+ id: 2,
+ title: __('Last updated'),
+ sortDirection: {
+ descending: 'updated_desc',
+ ascending: 'updated_asc',
+ },
+ },
+];
+
+export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
diff --git a/app/assets/javascripts/issues_list/eventhub.js b/app/assets/javascripts/issues_list/eventhub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/issues_list/eventhub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
new file mode 100644
index 00000000000..1ff41c20d08
--- /dev/null
+++ b/app/assets/javascripts/issues_list/index.js
@@ -0,0 +1,67 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import JiraIssuesListRoot from './components/jira_issues_list_root.vue';
+import IssuablesListApp from './components/issuables_list_app.vue';
+
+function mountJiraIssuesListApp() {
+ const el = document.querySelector('.js-projects-issues-root');
+
+ if (!el) {
+ return false;
+ }
+
+ Vue.use(VueApollo);
+
+ const defaultClient = createDefaultClient();
+ const apolloProvider = new VueApollo({
+ defaultClient,
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createComponent) {
+ return createComponent(JiraIssuesListRoot, {
+ props: {
+ canEdit: parseBoolean(el.dataset.canEdit),
+ isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
+ issuesPath: el.dataset.issuesPath,
+ projectPath: el.dataset.projectPath,
+ },
+ });
+ },
+ });
+}
+
+function mountIssuablesListApp() {
+ if (!gon.features?.vueIssuablesList && !gon.features?.jiraIssuesIntegration) {
+ return;
+ }
+
+ document.querySelectorAll('.js-issuables-list').forEach(el => {
+ const { canBulkEdit, emptyStateMeta = {}, ...data } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(IssuablesListApp, {
+ props: {
+ ...data,
+ emptyStateMeta:
+ Object.keys(emptyStateMeta).length !== 0
+ ? convertObjectPropsToCamelCase(JSON.parse(emptyStateMeta))
+ : {},
+ canBulkEdit: Boolean(canBulkEdit),
+ },
+ });
+ },
+ });
+ });
+}
+
+export default function initIssuablesList() {
+ mountJiraIssuesListApp();
+ mountIssuablesListApp();
+}
diff --git a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql
new file mode 100644
index 00000000000..8f9b888d19b
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql
@@ -0,0 +1,21 @@
+query($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ issues {
+ nodes {
+ labels {
+ nodes {
+ title
+ color
+ }
+ }
+ }
+ }
+ jiraImportStatus
+ jiraImports {
+ nodes {
+ importedIssuesCount
+ jiraProjectKey
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues_list/service_desk_helper.js b/app/assets/javascripts/issues_list/service_desk_helper.js
new file mode 100644
index 00000000000..0a34b754377
--- /dev/null
+++ b/app/assets/javascripts/issues_list/service_desk_helper.js
@@ -0,0 +1,111 @@
+import { __ } from '~/locale';
+
+/**
+ * Generates empty state messages for Service Desk issues list.
+ *
+ * @param {emptyStateMeta} emptyStateMeta - Meta data used to generate empty state messages
+ * @returns {Object} Object containing empty state messages generated using the meta data.
+ */
+export function generateMessages(emptyStateMeta) {
+ const {
+ svgPath,
+ serviceDeskHelpPage,
+ serviceDeskAddress,
+ editProjectPage,
+ incomingEmailHelpPage,
+ } = emptyStateMeta;
+
+ const serviceDeskSupportedTitle = __(
+ 'Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab',
+ );
+
+ const serviceDeskSupportedMessage = __(
+ 'Those emails automatically become issues (with the comments becoming the email conversation) listed here.',
+ );
+
+ const commonDescription = `
+ <span>${serviceDeskSupportedMessage}</span>
+ <a href="${serviceDeskHelpPage}">${__('Read more')}</a>`;
+
+ return {
+ serviceDeskEnabledAndCanEditProjectSettings: {
+ title: serviceDeskSupportedTitle,
+ svgPath,
+ description: `<p>${__('Have your users email')}
+ <code>${serviceDeskAddress}</code>
+ </p>
+ ${commonDescription}`,
+ },
+ serviceDeskEnabledAndCannotEditProjectSettings: {
+ title: serviceDeskSupportedTitle,
+ svgPath,
+ description: commonDescription,
+ },
+ serviceDeskDisabledAndCanEditProjectSettings: {
+ title: serviceDeskSupportedTitle,
+ svgPath,
+ description: commonDescription,
+ primaryLink: editProjectPage,
+ primaryText: __('Turn on Service Desk'),
+ },
+ serviceDeskDisabledAndCannotEditProjectSettings: {
+ title: serviceDeskSupportedTitle,
+ svgPath,
+ description: commonDescription,
+ },
+ serviceDeskIsNotSupported: {
+ title: __('Service Desk is not supported'),
+ svgPath,
+ description: __(
+ 'In order to enable Service Desk for your instance, you must first set up incoming email.',
+ ),
+ primaryLink: incomingEmailHelpPage,
+ primaryText: __('More information'),
+ },
+ serviceDeskIsNotEnabled: {
+ title: __('Service Desk is not enabled'),
+ svgPath,
+ description: __(
+ 'For help setting up the Service Desk for your instance, please contact an administrator.',
+ ),
+ },
+ };
+}
+
+/**
+ * Returns the attributes used for gl-empty-state in the Service Desk issues list.
+ *
+ * @param {Object} emptyStateMeta - Meta data used to generate empty state messages
+ * @returns {Object}
+ */
+export function emptyStateHelper(emptyStateMeta) {
+ const messages = generateMessages(emptyStateMeta);
+
+ const { isServiceDeskSupported, canEditProjectSettings, isServiceDeskEnabled } = emptyStateMeta;
+
+ if (isServiceDeskSupported) {
+ if (isServiceDeskEnabled && canEditProjectSettings) {
+ return messages.serviceDeskEnabledAndCanEditProjectSettings;
+ }
+
+ if (isServiceDeskEnabled && !canEditProjectSettings) {
+ return messages.serviceDeskEnabledAndCannotEditProjectSettings;
+ }
+
+ // !isServiceDeskEnabled && canEditProjectSettings
+ if (canEditProjectSettings) {
+ return messages.serviceDeskDisabledAndCanEditProjectSettings;
+ }
+
+ // !isServiceDeskEnabled && !canEditProjectSettings
+ return messages.serviceDeskDisabledAndCannotEditProjectSettings;
+ }
+
+ // !serviceDeskSupported && canEditProjectSettings
+ if (canEditProjectSettings) {
+ return messages.serviceDeskIsNotSupported;
+ }
+
+ // !serviceDeskSupported && !canEditProjectSettings
+ return messages.serviceDeskIsNotEnabled;
+}