Commit e30c2a00 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 33517ed1 82253cc6
/* eslint-disable class-methods-use-this, no-new */
import $ from 'jquery';
import { property } from 'lodash';
import issuableEventHub from '~/issues_list/eventhub';
import LabelsSelect from '~/labels/labels_select';
import MilestoneSelect from '~/milestones/milestone_select';
......@@ -16,8 +14,6 @@ const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-si
export default class IssuableBulkUpdateSidebar {
constructor() {
this.vueIssuablesListFeature = property(['gon', 'features', 'vueIssuablesList'])(window);
this.initDomElements();
this.bindEvents();
this.initDropdowns();
......@@ -143,7 +139,7 @@ export default class IssuableBulkUpdateSidebar {
}
toggleCheckboxDisplay(show) {
this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show || this.vueIssuablesListFeature);
this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show);
this.$issueChecks.toggleClass(HIDDEN_CLASS, !show);
}
......
......@@ -20,7 +20,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) =>
});
});
const initManualOrdering = (draggableSelector = 'li.issue') => {
const initManualOrdering = () => {
const issueList = document.querySelector('.manual-ordering');
if (!issueList || !(gon.current_user_id > 0)) {
......@@ -37,14 +37,14 @@ const initManualOrdering = (draggableSelector = 'li.issue') => {
group: {
name: 'issues',
},
draggable: draggableSelector,
draggable: 'li.issue',
onStart: () => {
sortableStart();
},
onUpdate: (event) => {
const el = event.item;
const url = el.getAttribute('url') || el.dataset.url;
const url = el.getAttribute('url');
const prev = el.previousElementSibling;
const next = el.nextElementSibling;
......
<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 jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg';
import {
GlLink,
GlTooltipDirective as GlTooltip,
GlSprintf,
GlLabel,
GlIcon,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { escape, isNumber } from 'lodash';
import { isScopedLabel } from '~/lib/utils/common_utils';
import {
dateInWords,
formatDate,
getDayDifference,
getTimeago,
timeFor,
newDateAsLocaleTime,
} from '~/lib/utils/datetime_utility';
import { convertToCamelCase } from '~/lib/utils/text_utility';
import { mergeUrlParams, setUrlFragment, isExternal } from '~/lib/utils/url_utility';
import { sprintf, __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import IssueAssignees from '~/issuable/components/issue_assignees.vue';
export default {
i18n: {
openedAgo: __('created %{timeAgoString} by %{user}'),
openedAgoJira: __('created %{timeAgoString} by %{user} in Jira'),
openedAgoServiceDesk: __('created %{timeAgoString} by %{email} via %{user}'),
},
components: {
IssueAssignees,
GlLink,
GlLabel,
GlIcon,
GlSprintf,
IssueHealthStatus: () =>
import('ee_component/related_items_tree/components/issue_health_status.vue'),
},
directives: {
GlTooltip,
SafeHtml,
},
inject: ['scopedLabelsAvailable'],
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 });
},
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';
},
webUrl() {
return this.issuable.gitlab_web_url || this.issuable.web_url;
},
isIssuableUrlExternal() {
return isExternal(this.webUrl);
},
linkTarget() {
return this.isIssuableUrlExternal ? '_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: setUrlFragment(this.webUrl, 'related-issues'),
icon: 'issue-block',
},
{
key: 'comments-count',
visible: !this.isJiraIssue,
value: this.issuable.user_notes_count,
title: __('Comments'),
dataTestId: 'notes-count',
href: setUrlFragment(this.webUrl, 'notes'),
class: { 'no-comments': !this.issuable.user_notes_count, 'issuable-comments': true },
icon: 'comments',
},
];
},
healthStatus() {
return convertToCamelCase(this.issuable.health_status);
},
openedMessage() {
if (this.isJiraIssue) return this.$options.i18n.openedAgoJira;
if (this.issuable.service_desk_reply_to) return this.$options.i18n.openedAgoServiceDesk;
return this.$options.i18n.openedAgo;
},
},
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="webUrl"
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="webUrl"
:target="linkTarget"
data-testid="issuable-title"
data-qa-selector="issue_link"
>
{{ issuable.title }}
<gl-icon
v-if="isIssuableUrlExternal"
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 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="openedMessage">
<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>
<template #email>
<span>{{ issuable.service_desk_reply_to }}</span>
</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')"
>
<gl-icon name="calendar" />
{{ 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-sm-display-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>
<script>
import {
GlEmptyState,
GlPagination,
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { toNumber, omit } from 'lodash';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { scrollToElement, historyPushState } from '~/lib/utils/common_utils';
import { setUrlParams, queryToObject, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import initManualOrdering from '~/issues/manual_ordering';
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 issuableEventHub from '../eventhub';
import { emptyStateHelper } from '../service_desk_helper';
import Issuable from './issuable.vue';
/**
* @deprecated Use app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue instead
*/
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') !== 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(() => {
issuableEventHub.$emit('issuables:updateBulkEdit');
});
},
issuables() {
this.$nextTick(() => {
initManualOrdering();
});
},
},
mounted() {
if (this.canBulkEdit) {
this.unsubscribeToggleBulkEdit = issuableEventHub.$on('issuables:toggleBulkEdit', (val) => {
this.isBulkEditing = val;
});
}
this.fetchIssuables();
},
beforeDestroy() {
// eslint-disable-next-line @gitlab/no-global-event-off
issuableEventHub.$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 createFlash({
message: __('An error occurred while loading issues'),
});
});
},
getQueryObject() {
return queryToObject(window.location.search, { gatherArrays: true });
},
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) {
const searchTokens = [];
filters.forEach((filter) => {
if (filter.type === 'filtered-search-term') {
if (filter.value.data) {
searchTokens.push(filter.value.data);
}
}
});
if (searchTokens.length) {
this.filters.search = searchTokens.join(' ');
}
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>
......@@ -9,62 +9,6 @@ import {
OPERATOR_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
// 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';
const TITLE = 'title';
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 },
title: { order_by: TITLE, sort: ASC },
title_desc: { order_by: TITLE, sort: DESC },
};
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 i18n = {
anonymousSearchingMessage: __('You must sign in to search for specific terms.'),
calendarLabel: __('Subscribe to calendar'),
......@@ -108,11 +52,13 @@ export const i18n = {
upvotes: __('Upvotes'),
};
export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
export const MAX_LIST_SIZE = 10;
export const PAGE_SIZE = 20;
export const PAGE_SIZE_MANUAL = 100;
export const PARAM_DUE_DATE = 'due_date';
export const PARAM_SORT = 'sort';
export const PARAM_STATE = 'state';
export const RELATIVE_POSITION = 'relative_position';
export const defaultPageSizeParams = {
firstPageSize: PAGE_SIZE,
......@@ -183,8 +129,6 @@ export const urlSortParams = {
[TITLE_DESC]: 'title_desc',
};
export const MAX_LIST_SIZE = 10;
export const API_PARAM = 'apiParam';
export const URL_PARAM = 'urlParam';
export const NORMAL_FILTER = 'normalFilter';
......
......@@ -4,8 +4,7 @@ import VueApollo from 'vue-apollo';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import IssuesListApp from 'ee_else_ce/issues_list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import IssuablesListApp from './components/issuables_list_app.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue';
export function mountJiraIssuesListApp() {
......@@ -45,35 +44,6 @@ export function mountJiraIssuesListApp() {
});
}
export function mountIssuablesListApp() {
if (!gon.features?.vueIssuablesList) {
return;
}
document.querySelectorAll('.js-issuables-list').forEach((el) => {
const { canBulkEdit, emptyStateMeta = {}, scopedLabelsAvailable, ...data } = el.dataset;
return new Vue({
el,
provide: {
scopedLabelsAvailable: parseBoolean(scopedLabelsAvailable),
},
render(createElement) {
return createElement(IssuablesListApp, {
props: {
...data,
emptyStateMeta:
Object.keys(emptyStateMeta).length !== 0
? convertObjectPropsToCamelCase(JSON.parse(emptyStateMeta))
: {},
canBulkEdit: Boolean(canBulkEdit),
},
});
},
});
});
}
export function mountIssuesListApp() {
const el = document.querySelector('.js-issues-list');
......
import { __, s__ } 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 = s__(
'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab',
);
const serviceDeskSupportedMessage = s__(
'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.',
);
const commonDescription = `
<span>${serviceDeskSupportedMessage}</span>
<a href="${serviceDeskHelpPage}">${__('Learn more.')}</a>`;
return {
serviceDeskEnabledAndCanEditProjectSettings: {
title: serviceDeskSupportedTitle,
svgPath,
description: `<p>${s__('ServiceDesk|Your users can send emails to this address:')}
<code>${serviceDeskAddress}</code>
</p>
${commonDescription}`,
},
serviceDeskEnabledAndCannotEditProjectSettings: {
title: serviceDeskSupportedTitle,
svgPath,
description: commonDescription,
},
serviceDeskDisabledAndCanEditProjectSettings: {
title: serviceDeskSupportedTitle,
svgPath,
description: commonDescription,
primaryLink: editProjectPage,
primaryText: s__('ServiceDesk|Enable Service Desk'),
},
serviceDeskDisabledAndCannotEditProjectSettings: {
title: serviceDeskSupportedTitle,
svgPath,
description: commonDescription,
},
serviceDeskIsNotSupported: {
title: s__('ServiceDesk|Service Desk is not supported'),
svgPath,
description: s__(
'ServiceDesk|To enable Service Desk on this instance, an instance administrator must first set up incoming email.',
),
primaryLink: incomingEmailHelpPage,
primaryText: __('Learn more.'),
},
serviceDeskIsNotEnabled: {
title: s__('ServiceDesk|Service Desk is not enabled'),
svgPath,
description: s__(
'ServiceDesk|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;
}
import { __ } from '~/locale';
export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
export const debounceWait = 500;
export const dropdownLabel = __(
......
import { last } from 'lodash';
import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issues_list/constants';
import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from './constants';
export const IMPORT_STATE = {
FAILED: 'failed',
......
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import { initBulkUpdateSidebar } from '~/issuable/bulk_update_sidebar';
import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list';
import { mountIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/issues/manual_ordering';
import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
......@@ -23,8 +23,4 @@ if (gon.features?.vueIssuesList) {
});
projectSelect();
initManualOrdering();
if (gon.features?.vueIssuablesList) {
mountIssuablesListApp();
}
}
......@@ -2,7 +2,7 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable';
import { initBulkUpdateSidebar, initIssueStatusSelect } from '~/issuable/bulk_update_sidebar';
import { mountIssuablesListApp, mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list';
import { mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/issues/manual_ordering';
import { FILTERED_SEARCH } from '~/filtered_search/constants';
import { ISSUABLE_INDEX } from '~/issuable/constants';
......@@ -27,10 +27,6 @@ if (gon.features?.vueIssuesList) {
initCsvImportExportButtons();
initIssuableByEmail();
initManualOrdering();
if (gon.features?.vueIssuablesList) {
mountIssuablesListApp();
}
}
new ShortcutsNavigation(); // eslint-disable-line no-new
......
import { initFilteredSearchServiceDesk } from '~/issues';
import { mountIssuablesListApp } from '~/issues_list';
initFilteredSearchServiceDesk();
if (gon.features?.vueIssuablesList) {
mountIssuablesListApp();
}
......@@ -32,7 +32,6 @@ class GroupsController < Groups::ApplicationController
before_action :user_actions, only: [:show]
before_action do
push_frontend_feature_flag(:vue_issuables_list, @group)
push_frontend_feature_flag(:vue_issues_list, @group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, @group, default_enabled: :yaml)
end
......
......@@ -43,7 +43,6 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
......
......@@ -168,21 +168,6 @@ module IssuesHelper
issue.moved_from.project.service_desk_enabled? && !issue.project.service_desk_enabled?
end
def use_startup_call?
request.query_parameters.empty? && @sort == 'created_date'
end
def startup_call_params
{
state: 'opened',
with_labels_details: 'true',
page: 1,
per_page: 20,
order_by: 'created_at',
sort: 'desc'
}
end
def issue_header_actions_data(project, issuable, current_user)
new_issuable_params = { issue: { description: _('Related to #%{issue_id}.') % { issue_id: issuable.iid } + "\n\n" } }
if issuable.incident?
......
# frozen_string_literal: true
module Projects::Issues::ServiceDeskHelper
def service_desk_meta(project)
empty_state_meta = {
is_service_desk_supported: Gitlab::ServiceDesk.supported?,
is_service_desk_enabled: project.service_desk_enabled?,
can_edit_project_settings: can?(current_user, :admin_project, project)
}
if Gitlab::ServiceDesk.supported?
empty_state_meta.merge(supported_meta(project))
else
empty_state_meta.merge(unsupported_meta(project))
end
end
private
def supported_meta(project)
{
service_desk_address: project.service_desk_address,
service_desk_help_page: help_page_path('user/project/service_desk'),
edit_project_page: edit_project_path(project),
svg_path: image_path('illustrations/service_desk_empty.svg')
}
end
def unsupported_meta(project)
{
incoming_email_help_page: help_page_path('administration/incoming_email', anchor: 'set-it-up'),
svg_path: image_path('illustrations/service-desk-setup.svg')
}
end
end
......@@ -136,13 +136,18 @@ module Ci
.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433')
}
# deprecated
scope :belonging_to_parent_group_of_project, -> (project_id) {
raise ArgumentError, "only 1 project_id allowed for performance reasons" unless project_id.is_a?(Integer)
project_groups = ::Group.joins(:projects).where(projects: { id: project_id })
joins(:groups)
.where(namespaces: { id: project_groups.self_and_ancestors.as_ids })
.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433')
if Feature.enabled?(:ci_decompose_belonging_to_parent_group_of_project_query, default_enabled: :yaml)
belonging_to_group(project_groups.self_and_ancestors.pluck(:id))
else
joins(:groups)
.where(namespaces: { id: project_groups.self_and_ancestors.as_ids })
.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433')
end
}
# deprecated
......
......@@ -25,14 +25,4 @@
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
- if Feature.enabled?(:vue_issuables_list, @group) && @issues.to_a.any?
- if use_startup_call?
- add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params))
.js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
'can-bulk-edit': @can_bulk_update.to_json,
'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
'sort-key': @sort,
type: 'issues',
'scoped-labels-available': scoped_labels_available?(@group).to_json } }
- else
= render 'shared/issues', project_select_button: true
= render 'shared/issues', project_select_button: true
-# DANGER: Any changes to this file need to be reflected in issuables_list/components/issuable.vue!
%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id, qa_selector: 'issue_container', qa_issue_title: issue.title } }
.issuable-info-container
- if @can_bulk_update
......
- is_project_overview = local_assigns.fetch(:is_project_overview, false)
= render 'shared/alerts/positioning_disabled' if @sort == 'relative_position'
- if Feature.enabled?(:vue_issuables_list, @project) && !is_project_overview
- data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
- default_empty_state_meta = { create_issue_path: new_project_issue_path(@project), svg_path: image_path('illustrations/issues.svg') }
- data_empty_state_meta = local_assigns.fetch(:data_empty_state_meta, default_empty_state_meta)
- type = local_assigns.fetch(:type, 'issues')
- if type == 'issues' && use_startup_call?
- add_page_startup_api_call(api_v4_projects_issues_path(id: @project.id, params: startup_call_params))
.js-issuables-list{ data: { endpoint: data_endpoint,
'empty-state-meta': data_empty_state_meta.to_json,
'can-bulk-edit': @can_bulk_update.to_json,
'sort-key': @sort,
type: type,
'scoped-labels-available': scoped_labels_available?(@project).to_json } }
- else
- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
%ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class }
= render partial: "projects/issues/issue", collection: @issues
- if @issues.blank?
= render empty_state_path
%ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class }
= render partial: "projects/issues/issue", collection: @issues
- if @issues.blank?
- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
= render empty_state_path
- if @issues.present?
= paginate_collection @issues, total_pages: @total_pages
- if @issues.present?
= paginate_collection @issues, total_pages: @total_pages
......@@ -27,7 +27,11 @@
.svg-content
= render 'shared/empty_states/icons/service_desk_setup.svg'
.text-content
%h4= s_('ServiceDesk|Service Desk is enabled but not yet active')
%p
= s_("ServiceDesk|To activate Service Desk on this instance, an instance administrator must first set up incoming email.")
= link_to _('Learn more.'), help_page_path('administration/incoming_email', anchor: 'set-it-up')
- if can_edit_project_settings
%h4= s_('ServiceDesk|Service Desk is not supported')
%p
= s_("ServiceDesk|To enable Service Desk on this instance, an instance administrator must first set up incoming email.")
= link_to _('Learn more.'), help_page_path('administration/incoming_email', anchor: 'set-it-up')
- else
%h4= s_('ServiceDesk|Service Desk is not enabled')
%p= s_("ServiceDesk|For help setting up the Service Desk for your instance, please contact an administrator.")
......@@ -7,9 +7,7 @@
- support_bot_attrs = { service_desk_enabled: @project.service_desk_enabled?, **UserSerializer.new.represent(User.support_bot) }.to_json
- data_endpoint = "#{expose_path(api_v4_projects_issues_path(id: @project.id))}?author_username=#{User.support_bot.username}"
%div{ class: "js-service-desk-issues service-desk-issues", data: { support_bot: support_bot_attrs, service_desk_meta: service_desk_meta(@project) } }
.js-service-desk-issues.service-desk-issues{ data: { support_bot: support_bot_attrs } }
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls.d-block.d-sm-none
......@@ -20,12 +18,5 @@
- if Gitlab::ServiceDesk.supported?
= render 'service_desk_info_content'
-# TODO Remove empty_state_path once vue_issuables_list FF is removed.
-# https://gitlab.com/gitlab-org/gitlab/-/issues/235652
-# `empty_state_path` is used to render the empty state in the HAML version of issuables list.
.issues-holder
= render 'projects/issues/issues',
empty_state_path: 'service_desk_empty_state',
data_endpoint: data_endpoint,
data_empty_state_meta: service_desk_meta(@project),
type: 'service_desk'
= render 'projects/issues/issues', empty_state_path: 'service_desk_empty_state'
---
name: vue_issuables_list
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/15091
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/208093
milestone: '12.5'
name: ci_decompose_belonging_to_parent_group_of_project_query
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76454
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/348560
milestone: '14.7'
type: development
group: group::project management
group: group::pipeline execution
default_enabled: false
......@@ -9,8 +9,6 @@ RSpec.describe 'Blocking issues count' do
let_it_be(:issue2) { build(:issue, project: project, created_at: 3.days.ago, title: 'blocks two issues') }
before do
stub_feature_flags(vue_issuables_list: false)
visit project_issues_path(project)
end
......
......@@ -147,14 +147,6 @@ RSpec.describe 'Filter issues by iteration', :js do
it_behaves_like 'filters by iteration'
it_behaves_like 'shows iterations when using iteration token'
context 'when vue_issuables_list is disabled' do
before do
stub_feature_flags(vue_issuables_list: false)
end
it_behaves_like 'filters by iteration'
end
end
context 'group issues list' do
......@@ -164,14 +156,6 @@ RSpec.describe 'Filter issues by iteration', :js do
it_behaves_like 'filters by iteration'
it_behaves_like 'shows iterations when using iteration token'
context 'when vue_issuables_list is disabled' do
before do
stub_feature_flags(vue_issuables_list: false)
end
it_behaves_like 'filters by iteration'
end
end
context 'project board' do
......
......@@ -21,7 +21,6 @@ RSpec.describe 'Filter issues by epic', :js do
before do
stub_licensed_features(epics: true)
stub_feature_flags(vue_issuables_list: false)
group.add_maintainer(user)
sign_in(user)
......
......@@ -14,14 +14,6 @@ RSpec.describe 'Filter issues weight', :js do
let(:filter_dropdown) { find("#js-dropdown-weight .filter-dropdown") }
shared_examples 'filters by negated weight' do
it 'excludes issues with specified weight' do
input_filtered_search(search)
expect_issues_list_count(1)
end
end
def expect_issues_list_count(open_count, closed_count = 0)
all_count = open_count + closed_count
......@@ -57,14 +49,10 @@ RSpec.describe 'Filter issues weight', :js do
describe 'negated weight only' do
let(:search) { 'weight:!=2' }
it_behaves_like 'filters by negated weight'
context 'when vue_issuables_list is disabled' do
before do
stub_feature_flags(vue_issuables_list: false)
end
it 'excludes issues with specified weight' do
input_filtered_search(search)
it_behaves_like 'filters by negated weight'
expect_issues_list_count(1)
end
end
......
......@@ -10,7 +10,6 @@ RSpec.describe 'User views issues page', :js do
let_it_be(:issue3) { create(:issue, project: project, health_status: 'at_risk') }
before do
stub_feature_flags(vue_issuables_list: false)
sign_in(user)
visit project_issues_path(project)
end
......
......@@ -841,9 +841,6 @@ msgstr ""
msgid "%{policy_link} (notifying after %{elapsed_time} minutes unless %{status})"
msgstr ""
msgid "%{primary} (%{secondary})"
msgstr ""
msgid "%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}"
msgstr ""
......@@ -31098,9 +31095,6 @@ msgstr ""
msgid "Search GitLab"
msgstr ""
msgid "Search Jira issues"
msgstr ""
msgid "Search a group"
msgstr ""
......@@ -32419,18 +32413,12 @@ msgstr ""
msgid "ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation."
msgstr ""
msgid "ServiceDesk|Service Desk is enabled but not yet active"
msgstr ""
msgid "ServiceDesk|Service Desk is not enabled"
msgstr ""
msgid "ServiceDesk|Service Desk is not supported"
msgstr ""
msgid "ServiceDesk|To activate Service Desk on this instance, an instance administrator must first set up incoming email."
msgstr ""
msgid "ServiceDesk|To enable Service Desk on this instance, an instance administrator must first set up incoming email."
msgstr ""
......@@ -41925,12 +41913,6 @@ msgstr ""
msgid "created %{timeAgoString} by %{email} via %{user}"
msgstr ""
msgid "created %{timeAgoString} by %{user}"
msgstr ""
msgid "created %{timeAgoString} by %{user} in Jira"
msgstr ""
msgid "created %{timeAgo}"
msgstr ""
......
......@@ -9,8 +9,6 @@ RSpec.describe 'Service Desk Issue Tracker', :js do
let_it_be(:support_bot) { User.support_bot }
before do
stub_feature_flags(vue_issuables_list: true)
# The following two conditions equate to Gitlab::ServiceDesk.supported == true
allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
......
......@@ -8,8 +8,6 @@ RSpec.describe 'New issue breadcrumb' do
let(:user) { project.creator }
before do
stub_feature_flags(vue_issuables_list: false)
sign_in(user)
visit(new_project_issue_path(project))
end
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Issuables list component with empty issues response with all state should display a catch-all if there are no issues to show 1`] = `
<gl-empty-state-stub
svgpath="/emptySvg"
title="There are no issues to show"
/>
`;
exports[`Issuables list component with empty issues response with closed state should display a message "There are no closed issues" if there are no closed issues 1`] = `"There are no closed issues"`;
exports[`Issuables list component with empty issues response with empty query should display the message "There are no open issues" 1`] = `"There are no open issues"`;
exports[`Issuables list component with empty issues response with query in window location should display "Sorry, your filter produced no results" if filters are too specific 1`] = `"Sorry, your filter produced no results"`;
import { GlSprintf, GlLabel, GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import { trimText } from 'helpers/text_helper';
import Issuable from '~/issues_list/components/issuable.vue';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { formatDate } from '~/lib/utils/datetime_utility';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
import IssueAssignees from '~/issuable/components/issue_assignees.vue';
import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data';
jest.mock('~/user_popovers');
const TODAY = new Date();
const createTestDateFromDelta = (timeDelta) =>
formatDate(new Date(TODAY.getTime() + timeDelta), 'yyyy-mm-dd');
// TODO: Encapsulate date helpers https://gitlab.com/gitlab-org/gitlab/-/issues/320883
const MONTHS_IN_MS = 1000 * 60 * 60 * 24 * 31;
const TEST_MONTH_AGO = createTestDateFromDelta(-MONTHS_IN_MS);
const TEST_MONTH_LATER = createTestDateFromDelta(MONTHS_IN_MS);
const DATE_FORMAT = 'mmm d, yyyy';
const TEST_USER_NAME = 'Tyler Durden';
const TEST_BASE_URL = `${TEST_HOST}/issues`;
const TEST_TASK_STATUS = '50 of 100 tasks completed';
const TEST_MILESTONE = {
title: 'Milestone title',
web_url: `${TEST_HOST}/milestone/1`,
};
const TEXT_CLOSED = 'CLOSED';
const TEST_META_COUNT = 100;
const MOCK_GITLAB_URL = 'http://0.0.0.0:3000';
describe('Issuable component', () => {
let issuable;
let wrapper;
const factory = (props = {}, scopedLabelsAvailable = false) => {
wrapper = shallowMount(Issuable, {
propsData: {
issuable: simpleIssue,
baseUrl: TEST_BASE_URL,
...props,
},
provide: {
scopedLabelsAvailable,
},
stubs: {
'gl-sprintf': GlSprintf,
},
});
};
beforeEach(() => {
issuable = { ...simpleIssue };
gon.gitlab_url = MOCK_GITLAB_URL;
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const checkExists = (findFn) => () => findFn().exists();
const hasIcon = (iconName, iconWrapper = wrapper) =>
iconWrapper.findAll(GlIcon).wrappers.some((icon) => icon.props('name') === iconName);
const hasConfidentialIcon = () => hasIcon('eye-slash');
const findTaskStatus = () => wrapper.find('.task-status');
const findOpenedAgoContainer = () => wrapper.find('[data-testid="openedByMessage"]');
const findAuthor = () => wrapper.find({ ref: 'openedAgoByContainer' });
const findMilestone = () => wrapper.find('.js-milestone');
const findMilestoneTooltip = () => findMilestone().attributes('title');
const findDueDate = () => wrapper.find('.js-due-date');
const findLabels = () => wrapper.findAll(GlLabel);
const findWeight = () => wrapper.find('[data-testid="weight"]');
const findAssignees = () => wrapper.find(IssueAssignees);
const findBlockingIssuesCount = () => wrapper.find('[data-testid="blocking-issues"]');
const findMergeRequestsCount = () => wrapper.find('[data-testid="merge-requests"]');
const findUpvotes = () => wrapper.find('[data-testid="upvotes"]');
const findDownvotes = () => wrapper.find('[data-testid="downvotes"]');
const findNotes = () => wrapper.find('[data-testid="notes-count"]');
const findBulkCheckbox = () => wrapper.find('input.selected-issuable');
const findScopedLabels = () => findLabels().filter((w) => isScopedLabel({ title: w.text() }));
const findUnscopedLabels = () => findLabels().filter((w) => !isScopedLabel({ title: w.text() }));
const findIssuableTitle = () => wrapper.find('[data-testid="issuable-title"]');
const findIssuableStatus = () => wrapper.find('[data-testid="issuable-status"]');
const containsJiraLogo = () => wrapper.find('[data-testid="jira-logo"]').exists();
const findHealthStatus = () => wrapper.find('.health-status');
describe('when mounted', () => {
it('initializes user popovers', () => {
expect(initUserPopovers).not.toHaveBeenCalled();
factory();
expect(initUserPopovers).toHaveBeenCalledWith([wrapper.vm.$refs.openedAgoByContainer.$el]);
});
});
describe('when scopedLabels feature is available', () => {
beforeEach(() => {
issuable.labels = [...testLabels];
factory({ issuable }, true);
});
describe('when label is scoped', () => {
it('returns label with correct props', () => {
const scopedLabel = findScopedLabels().at(0);
expect(scopedLabel.props('scoped')).toBe(true);
});
});
describe('when label is not scoped', () => {
it('returns label with correct props', () => {
const notScopedLabel = findUnscopedLabels().at(0);
expect(notScopedLabel.props('scoped')).toBe(false);
});
});
});
describe('when scopedLabels feature is not available', () => {
beforeEach(() => {
issuable.labels = [...testLabels];
factory({ issuable });
});
describe('when label is scoped', () => {
it('label scoped props is false', () => {
const scopedLabel = findScopedLabels().at(0);
expect(scopedLabel.props('scoped')).toBe(false);
});
});
describe('when label is not scoped', () => {
it('label scoped props is false', () => {
const notScopedLabel = findUnscopedLabels().at(0);
expect(notScopedLabel.props('scoped')).toBe(false);
});
});
});
describe('with simple issuable', () => {
beforeEach(() => {
Object.assign(issuable, {
has_tasks: false,
task_status: TEST_TASK_STATUS,
created_at: TEST_MONTH_AGO,
author: {
...issuable.author,
name: TEST_USER_NAME,
},
labels: [],
});
factory({ issuable });
});
it.each`
desc | check
${'bulk editing checkbox'} | ${checkExists(findBulkCheckbox)}
${'confidential icon'} | ${hasConfidentialIcon}
${'task status'} | ${checkExists(findTaskStatus)}
${'milestone'} | ${checkExists(findMilestone)}
${'due date'} | ${checkExists(findDueDate)}
${'labels'} | ${checkExists(findLabels)}
${'weight'} | ${checkExists(findWeight)}
${'blocking issues count'} | ${checkExists(findBlockingIssuesCount)}
${'merge request count'} | ${checkExists(findMergeRequestsCount)}
${'upvotes'} | ${checkExists(findUpvotes)}
${'downvotes'} | ${checkExists(findDownvotes)}
`('does not render $desc', ({ check }) => {
expect(check()).toBe(false);
});
it('show relative reference path', () => {
expect(wrapper.find('.js-ref-path').text()).toBe(issuable.references.relative);
});
it('does not have closed text', () => {
expect(wrapper.text()).not.toContain(TEXT_CLOSED);
});
it('does not have closed class', () => {
expect(wrapper.classes('closed')).toBe(false);
});
it('renders fuzzy created date and author', () => {
expect(trimText(findOpenedAgoContainer().text())).toContain(
`created 1 month ago by ${TEST_USER_NAME}`,
);
});
it('renders no comments', () => {
expect(findNotes().classes('no-comments')).toBe(true);
});
it.each`
gitlabWebUrl | webUrl | expectedHref | expectedTarget | isExternal
${undefined} | ${`${MOCK_GITLAB_URL}/issue`} | ${`${MOCK_GITLAB_URL}/issue`} | ${undefined} | ${false}
${undefined} | ${'https://jira.com/issue'} | ${'https://jira.com/issue'} | ${'_blank'} | ${true}
${'/gitlab-org/issue'} | ${'https://jira.com/issue'} | ${'/gitlab-org/issue'} | ${undefined} | ${false}
`(
'renders issuable title correctly when `gitlabWebUrl` is `$gitlabWebUrl` and webUrl is `$webUrl`',
async ({ webUrl, gitlabWebUrl, expectedHref, expectedTarget, isExternal }) => {
factory({
issuable: {
...issuable,
web_url: webUrl,
gitlab_web_url: gitlabWebUrl,
},
});
const titleEl = findIssuableTitle();
expect(titleEl.exists()).toBe(true);
expect(titleEl.find(GlLink).attributes('href')).toBe(expectedHref);
expect(titleEl.find(GlLink).attributes('target')).toBe(expectedTarget);
expect(titleEl.find(GlLink).text()).toBe(issuable.title);
expect(titleEl.find(GlIcon).exists()).toBe(isExternal);
},
);
});
describe('with confidential issuable', () => {
beforeEach(() => {
issuable.confidential = true;
factory({ issuable });
});
it('renders the confidential icon', () => {
expect(hasConfidentialIcon()).toBe(true);
});
});
describe('with Jira issuable', () => {
beforeEach(() => {
issuable.external_tracker = 'jira';
factory({ issuable });
});
it('renders the Jira icon', () => {
expect(containsJiraLogo()).toBe(true);
});
it('opens issuable in a new tab', () => {
expect(findIssuableTitle().props('target')).toBe('_blank');
});
it('opens author in a new tab', () => {
expect(findAuthor().props('target')).toBe('_blank');
});
describe('with Jira status', () => {
const expectedStatus = 'In Progress';
beforeEach(() => {
issuable.status = expectedStatus;
factory({ issuable });
});
it('renders the Jira status', () => {
expect(findIssuableStatus().text()).toBe(expectedStatus);
});
});
});
describe('with task status', () => {
beforeEach(() => {
Object.assign(issuable, {
has_tasks: true,
task_status: TEST_TASK_STATUS,
});
factory({ issuable });
});
it('renders task status', () => {
expect(findTaskStatus().exists()).toBe(true);
expect(findTaskStatus().text()).toBe(TEST_TASK_STATUS);
});
});
describe.each`
desc | dueDate | expectedTooltipPart
${'past due'} | ${TEST_MONTH_AGO} | ${'Past due'}
${'future due'} | ${TEST_MONTH_LATER} | ${'1 month remaining'}
`('with milestone with $desc', ({ dueDate, expectedTooltipPart }) => {
beforeEach(() => {
issuable.milestone = { ...TEST_MILESTONE, due_date: dueDate };
factory({ issuable });
});
it('renders milestone', () => {
expect(findMilestone().exists()).toBe(true);
expect(hasIcon('clock', findMilestone())).toBe(true);
expect(findMilestone().text()).toEqual(TEST_MILESTONE.title);
});
it('renders tooltip', () => {
expect(findMilestoneTooltip()).toBe(
`${formatDate(dueDate, DATE_FORMAT)} (${expectedTooltipPart})`,
);
});
it('renders milestone with the correct href', () => {
const { title } = issuable.milestone;
const expected = mergeUrlParams({ milestone_title: title }, TEST_BASE_URL);
expect(findMilestone().attributes('href')).toBe(expected);
});
});
describe.each`
dueDate | hasClass | desc
${TEST_MONTH_LATER} | ${false} | ${'with future due date'}
${TEST_MONTH_AGO} | ${true} | ${'with past due date'}
`('$desc', ({ dueDate, hasClass }) => {
beforeEach(() => {
issuable.due_date = dueDate;
factory({ issuable });
});
it('renders due date', () => {
expect(findDueDate().exists()).toBe(true);
expect(findDueDate().text()).toBe(formatDate(dueDate, DATE_FORMAT));
});
it(hasClass ? 'has cred class' : 'does not have cred class', () => {
expect(findDueDate().classes('cred')).toEqual(hasClass);
});
});
describe('with labels', () => {
beforeEach(() => {
issuable.labels = [...testLabels];
factory({ issuable });
});
it('renders labels', () => {
factory({ issuable });
const labels = findLabels().wrappers.map((label) => ({
href: label.props('target'),
text: label.text(),
tooltip: label.attributes('description'),
}));
const expected = testLabels.map((label) => ({
href: mergeUrlParams({ 'label_name[]': label.name }, TEST_BASE_URL),
text: label.name,
tooltip: label.description,
}));
expect(labels).toEqual(expected);
});
});
describe('with labels for Jira issuable', () => {
beforeEach(() => {
issuable.labels = [...testLabels];
issuable.external_tracker = 'jira';
factory({ issuable });
});
it('renders labels', () => {
factory({ issuable });
const labels = findLabels().wrappers.map((label) => ({
href: label.props('target'),
text: label.text(),
tooltip: label.attributes('description'),
}));
const expected = testLabels.map((label) => ({
href: mergeUrlParams({ 'labels[]': label.name }, TEST_BASE_URL),
text: label.name,
tooltip: label.description,
}));
expect(labels).toEqual(expected);
});
});
describe.each`
weight
${0}
${10}
${12345}
`('with weight $weight', ({ weight }) => {
beforeEach(() => {
issuable.weight = weight;
factory({ issuable });
});
it('renders weight', () => {
expect(findWeight().exists()).toBe(true);
expect(findWeight().text()).toEqual(weight.toString());
});
});
describe('with closed state', () => {
beforeEach(() => {
issuable.state = 'closed';
factory({ issuable });
});
it('renders closed text', () => {
expect(wrapper.text()).toContain(TEXT_CLOSED);
});
it('has closed class', () => {
expect(wrapper.classes('closed')).toBe(true);
});
});
describe('with assignees', () => {
beforeEach(() => {
issuable.assignees = testAssignees;
factory({ issuable });
});
it('renders assignees', () => {
expect(findAssignees().exists()).toBe(true);
expect(findAssignees().props('assignees')).toEqual(testAssignees);
});
});
describe.each`
desc | key | finder
${'with blocking issues count'} | ${'blocking_issues_count'} | ${findBlockingIssuesCount}
${'with merge requests count'} | ${'merge_requests_count'} | ${findMergeRequestsCount}
${'with upvote count'} | ${'upvotes'} | ${findUpvotes}
${'with downvote count'} | ${'downvotes'} | ${findDownvotes}
${'with notes count'} | ${'user_notes_count'} | ${findNotes}
`('$desc', ({ key, finder }) => {
beforeEach(() => {
issuable[key] = TEST_META_COUNT;
factory({ issuable });
});
it('renders correct count', () => {
expect(finder().exists()).toBe(true);
expect(finder().text()).toBe(TEST_META_COUNT.toString());
expect(finder().classes('no-comments')).toBe(false);
});
});
describe('with bulk editing', () => {
describe.each`
selected | desc
${true} | ${'when selected'}
${false} | ${'when unselected'}
`('$desc', ({ selected }) => {
beforeEach(() => {
factory({ isBulkEditing: true, selected });
});
it(`renders checked is ${selected}`, () => {
expect(findBulkCheckbox().element.checked).toBe(selected);
});
it('emits select when clicked', () => {
expect(wrapper.emitted().select).toBeUndefined();
findBulkCheckbox().trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted().select).toEqual([[{ issuable, selected: !selected }]]);
});
});
});
});
if (IS_EE) {
describe('with health status', () => {
it('renders health status tag', () => {
factory({ issuable });
expect(findHealthStatus().exists()).toBe(true);
});
it('does not render when health status is absent', () => {
issuable.health_status = null;
factory({ issuable });
expect(findHealthStatus().exists()).toBe(false);
});
});
}
});
import {
GlEmptyState,
GlPagination,
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
} from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import Issuable from '~/issues_list/components/issuable.vue';
import IssuablesListApp from '~/issues_list/components/issuables_list_app.vue';
import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issues_list/constants';
import issuablesEventBus from '~/issues_list/eventhub';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
jest.mock('~/flash');
jest.mock('~/issues_list/eventhub');
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
scrollToElement: () => {},
}));
const TEST_LOCATION = `${TEST_HOST}/issues`;
const TEST_ENDPOINT = '/issues';
const TEST_CREATE_ISSUES_PATH = '/createIssue';
const TEST_SVG_PATH = '/emptySvg';
const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL)
.fill(0)
.map((_, i) => ({
id: i,
web_url: `url${i}`,
}));
describe('Issuables list component', () => {
let mockAxios;
let wrapper;
let apiSpy;
const setupApiMock = (cb) => {
apiSpy = jest.fn(cb);
mockAxios.onGet(TEST_ENDPOINT).reply((cfg) => apiSpy(cfg));
};
const factory = (props = { sortKey: 'priority' }) => {
const emptyStateMeta = {
createIssuePath: TEST_CREATE_ISSUES_PATH,
svgPath: TEST_SVG_PATH,
};
wrapper = shallowMount(IssuablesListApp, {
propsData: {
endpoint: TEST_ENDPOINT,
emptyStateMeta,
...props,
},
});
};
const findLoading = () => wrapper.find(GlSkeletonLoading);
const findIssuables = () => wrapper.findAll(Issuable);
const findFilteredSearchBar = () => wrapper.find(FilteredSearchBar);
const findFirstIssuable = () => findIssuables().wrappers[0];
const findEmptyState = () => wrapper.find(GlEmptyState);
beforeEach(() => {
mockAxios = new MockAdapter(axios);
setWindowLocation(TEST_LOCATION);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
mockAxios.restore();
});
describe('with failed issues response', () => {
beforeEach(() => {
setupApiMock(() => [500]);
factory();
return waitForPromises();
});
it('does not show loading', () => {
expect(wrapper.vm.loading).toBe(false);
});
it('flashes an error', () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
describe('with successful issues response', () => {
beforeEach(() => {
setupApiMock(() => [
200,
MOCK_ISSUES.slice(0, PAGE_SIZE),
{
'x-total': 100,
'x-page': 2,
},
]);
});
it('has default props and data', () => {
factory();
expect(wrapper.vm).toMatchObject({
// Props
canBulkEdit: false,
emptyStateMeta: {
createIssuePath: TEST_CREATE_ISSUES_PATH,
svgPath: TEST_SVG_PATH,
},
// Data
filters: {
state: 'opened',
},
isBulkEditing: false,
issuables: [],
loading: true,
page: 1,
selection: {},
totalItems: 0,
});
});
it('does not call API until mounted', () => {
factory();
expect(apiSpy).not.toHaveBeenCalled();
});
describe('when mounted', () => {
beforeEach(() => {
factory();
});
it('calls API', () => {
expect(apiSpy).toHaveBeenCalled();
});
it('shows loading', () => {
expect(findLoading().exists()).toBe(true);
expect(findIssuables().length).toBe(0);
expect(findEmptyState().exists()).toBe(false);
});
});
describe('when finished loading', () => {
beforeEach(() => {
factory();
return waitForPromises();
});
it('does not display empty state', () => {
expect(wrapper.vm.issuables.length).toBeGreaterThan(0);
expect(wrapper.vm.emptyState).toEqual({});
expect(wrapper.find(GlEmptyState).exists()).toBe(false);
});
it('sets the proper page and total items', () => {
expect(wrapper.vm.totalItems).toBe(100);
expect(wrapper.vm.page).toBe(2);
});
it('renders one page of issuables and pagination', () => {
expect(findIssuables().length).toBe(PAGE_SIZE);
expect(wrapper.find(GlPagination).exists()).toBe(true);
});
});
it('does not render FilteredSearchBar', () => {
factory();
expect(findFilteredSearchBar().exists()).toBe(false);
});
});
describe('with bulk editing enabled', () => {
beforeEach(() => {
issuablesEventBus.$on.mockReset();
issuablesEventBus.$emit.mockReset();
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
factory({ canBulkEdit: true });
return waitForPromises();
});
it('is not enabled by default', () => {
expect(wrapper.vm.isBulkEditing).toBe(false);
});
it('does not select issues by default', () => {
expect(wrapper.vm.selection).toEqual({});
});
it('"Select All" checkbox toggles all visible issuables"', () => {
wrapper.vm.onSelectAll();
expect(wrapper.vm.selection).toEqual(
wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}),
);
wrapper.vm.onSelectAll();
expect(wrapper.vm.selection).toEqual({});
});
it('"Select All checkbox" selects all issuables if only some are selected"', () => {
wrapper.vm.selection = { [wrapper.vm.issuables[0].id]: true };
wrapper.vm.onSelectAll();
expect(wrapper.vm.selection).toEqual(
wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}),
);
});
it('selects and deselects issuables', () => {
const [i0, i1, i2] = wrapper.vm.issuables;
expect(wrapper.vm.selection).toEqual({});
wrapper.vm.onSelectIssuable({ issuable: i0, selected: false });
expect(wrapper.vm.selection).toEqual({});
wrapper.vm.onSelectIssuable({ issuable: i1, selected: true });
expect(wrapper.vm.selection).toEqual({ 1: true });
wrapper.vm.onSelectIssuable({ issuable: i0, selected: true });
expect(wrapper.vm.selection).toEqual({ 1: true, 0: true });
wrapper.vm.onSelectIssuable({ issuable: i2, selected: true });
expect(wrapper.vm.selection).toEqual({ 1: true, 0: true, 2: true });
wrapper.vm.onSelectIssuable({ issuable: i2, selected: true });
expect(wrapper.vm.selection).toEqual({ 1: true, 0: true, 2: true });
wrapper.vm.onSelectIssuable({ issuable: i0, selected: false });
expect(wrapper.vm.selection).toEqual({ 1: true, 2: true });
});
it('broadcasts a message to the bulk edit sidebar when a value is added to selection', () => {
issuablesEventBus.$emit.mockReset();
const i1 = wrapper.vm.issuables[1];
wrapper.vm.onSelectIssuable({ issuable: i1, selected: true });
return wrapper.vm.$nextTick().then(() => {
expect(issuablesEventBus.$emit).toHaveBeenCalledTimes(1);
expect(issuablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
});
});
it('does not broadcast a message to the bulk edit sidebar when a value is not added to selection', () => {
issuablesEventBus.$emit.mockReset();
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
const i1 = wrapper.vm.issuables[1];
wrapper.vm.onSelectIssuable({ issuable: i1, selected: false });
})
.then(wrapper.vm.$nextTick)
.then(() => {
expect(issuablesEventBus.$emit).toHaveBeenCalledTimes(0);
});
});
it('listens to a message to toggle bulk editing', () => {
expect(wrapper.vm.isBulkEditing).toBe(false);
expect(issuablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit');
issuablesEventBus.$on.mock.calls[0][1](true); // Call the message handler
return waitForPromises()
.then(() => {
expect(wrapper.vm.isBulkEditing).toBe(true);
issuablesEventBus.$on.mock.calls[0][1](false);
})
.then(() => {
expect(wrapper.vm.isBulkEditing).toBe(false);
});
});
});
describe('with query params in window.location', () => {
const expectedFilters = {
assignee_username: 'root',
author_username: 'root',
confidential: 'yes',
my_reaction_emoji: 'airplane',
scope: 'all',
state: 'opened',
weight: '0',
milestone: 'v3.0',
labels: 'Aquapod,Astro',
order_by: 'milestone_due',
sort: 'desc',
};
describe('when page is not present in params', () => {
const query =
'?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0&not[label_name][]=Afterpod&not[milestone_title][]=13';
beforeEach(() => {
setWindowLocation(query);
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
factory({ sortKey: 'milestone_due_desc' });
return waitForPromises();
});
afterEach(() => {
apiSpy.mockClear();
});
it('applies filters and sorts', () => {
expect(wrapper.vm.hasFilters).toBe(true);
expect(wrapper.vm.filters).toEqual({
...expectedFilters,
'not[milestone]': ['13'],
'not[labels]': ['Afterpod'],
});
expect(apiSpy).toHaveBeenCalledWith(
expect.objectContaining({
params: {
...expectedFilters,
with_labels_details: true,
page: 1,
per_page: PAGE_SIZE,
'not[milestone]': ['13'],
'not[labels]': ['Afterpod'],
},
}),
);
});
it('passes the base url to issuable', () => {
expect(findFirstIssuable().props('baseUrl')).toBe(TEST_LOCATION);
});
});
describe('when page is present in the param', () => {
const query =
'?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0&page=3';
beforeEach(() => {
setWindowLocation(query);
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
factory({ sortKey: 'milestone_due_desc' });
return waitForPromises();
});
afterEach(() => {
apiSpy.mockClear();
});
it('applies filters and sorts', () => {
expect(apiSpy).toHaveBeenCalledWith(
expect.objectContaining({
params: {
...expectedFilters,
with_labels_details: true,
page: 3,
per_page: PAGE_SIZE,
},
}),
);
});
});
});
describe('with hash in window.location', () => {
beforeEach(() => {
setWindowLocation(`${TEST_LOCATION}#stuff`);
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
factory();
return waitForPromises();
});
it('passes the base url to issuable', () => {
expect(findFirstIssuable().props('baseUrl')).toBe(TEST_LOCATION);
});
});
describe('with manual sort', () => {
beforeEach(() => {
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
factory({ sortKey: RELATIVE_POSITION });
});
it('uses manual page size', () => {
expect(apiSpy).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
per_page: PAGE_SIZE_MANUAL,
}),
}),
);
});
});
describe('with empty issues response', () => {
beforeEach(() => {
setupApiMock(() => [200, []]);
});
describe('with query in window location', () => {
beforeEach(() => {
setWindowLocation('?weight=Any');
factory();
return waitForPromises().then(() => wrapper.vm.$nextTick());
});
it('should display "Sorry, your filter produced no results" if filters are too specific', () => {
expect(findEmptyState().props('title')).toMatchSnapshot();
});
});
describe('with closed state', () => {
beforeEach(() => {
setWindowLocation('?state=closed');
factory();
return waitForPromises().then(() => wrapper.vm.$nextTick());
});
it('should display a message "There are no closed issues" if there are no closed issues', () => {
expect(findEmptyState().props('title')).toMatchSnapshot();
});
});
describe('with all state', () => {
beforeEach(() => {
setWindowLocation('?state=all');
factory();
return waitForPromises().then(() => wrapper.vm.$nextTick());
});
it('should display a catch-all if there are no issues to show', () => {
expect(findEmptyState().element).toMatchSnapshot();
});
});
describe('with empty query', () => {
beforeEach(() => {
factory();
return wrapper.vm.$nextTick().then(waitForPromises);
});
it('should display the message "There are no open issues"', () => {
expect(findEmptyState().props('title')).toMatchSnapshot();
});
});
});
describe('when paginates', () => {
const newPage = 3;
describe('when total-items is defined in response headers', () => {
beforeEach(() => {
window.history.pushState = jest.fn();
setupApiMock(() => [
200,
MOCK_ISSUES.slice(0, PAGE_SIZE),
{
'x-total': 100,
'x-page': 2,
},
]);
factory();
return waitForPromises();
});
afterEach(() => {
// reset to original value
window.history.pushState.mockRestore();
});
it('calls window.history.pushState one time', () => {
// Trigger pagination
wrapper.find(GlPagination).vm.$emit('input', newPage);
expect(window.history.pushState).toHaveBeenCalledTimes(1);
});
it('sets params in the url', () => {
// Trigger pagination
wrapper.find(GlPagination).vm.$emit('input', newPage);
expect(window.history.pushState).toHaveBeenCalledWith(
{},
'',
`${TEST_LOCATION}?state=opened&order_by=priority&sort=asc&page=${newPage}`,
);
});
});
describe('when total-items is not defined in the headers', () => {
const page = 2;
const prevPage = page - 1;
const nextPage = page + 1;
beforeEach(() => {
setupApiMock(() => [
200,
MOCK_ISSUES.slice(0, PAGE_SIZE),
{
'x-page': page,
},
]);
factory();
return waitForPromises();
});
it('finds the correct props applied to GlPagination', () => {
expect(wrapper.find(GlPagination).props()).toMatchObject({
nextPage,
prevPage,
value: page,
});
});
});
});
describe('when type is "jira"', () => {
it('renders FilteredSearchBar', () => {
factory({ type: 'jira' });
expect(findFilteredSearchBar().exists()).toBe(true);
});
describe('initialSortBy', () => {
const query = '?sort=updated_asc';
it('sets default value', () => {
factory({ type: 'jira' });
expect(findFilteredSearchBar().props('initialSortBy')).toBe('created_desc');
});
it('sets value according to query', () => {
setWindowLocation(query);
factory({ type: 'jira' });
expect(findFilteredSearchBar().props('initialSortBy')).toBe('updated_asc');
});
});
describe('initialFilterValue', () => {
it('does not set value when no query', () => {
factory({ type: 'jira' });
expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([]);
});
it('sets value according to query', () => {
const query = '?search=free+text';
setWindowLocation(query);
factory({ type: 'jira' });
expect(findFilteredSearchBar().props('initialFilterValue')).toEqual(['free text']);
});
});
describe('on filter search', () => {
beforeEach(() => {
factory({ type: 'jira' });
window.history.pushState = jest.fn();
});
afterEach(() => {
window.history.pushState.mockRestore();
});
const emitOnFilter = (filter) => findFilteredSearchBar().vm.$emit('onFilter', filter);
describe('empty filter', () => {
const mockFilter = [];
it('updates URL with correct params', () => {
emitOnFilter(mockFilter);
expect(window.history.pushState).toHaveBeenCalledWith(
{},
'',
`${TEST_LOCATION}?state=opened`,
);
});
});
describe('filter with search term', () => {
const mockFilter = [
{
type: 'filtered-search-term',
value: { data: 'free' },
},
];
it('updates URL with correct params', () => {
emitOnFilter(mockFilter);
expect(window.history.pushState).toHaveBeenCalledWith(
{},
'',
`${TEST_LOCATION}?state=opened&search=free`,
);
});
});
describe('filter with multiple search terms', () => {
const mockFilter = [
{
type: 'filtered-search-term',
value: { data: 'free' },
},
{
type: 'filtered-search-term',
value: { data: 'text' },
},
];
it('updates URL with correct params', () => {
emitOnFilter(mockFilter);
expect(window.history.pushState).toHaveBeenCalledWith(
{},
'',
`${TEST_LOCATION}?state=opened&search=free+text`,
);
});
});
});
});
});
export const simpleIssue = {
id: 442,
iid: 31,
title: 'Dismiss Cipher with no integrity',
state: 'opened',
created_at: '2019-08-26T19:06:32.667Z',
updated_at: '2019-08-28T19:53:58.314Z',
labels: [],
milestone: null,
assignees: [],
author: {
id: 3,
name: 'Elnora Bernhard',
username: 'treva.lesch',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/a8c0d9c2882406cf2a9b71494625a796?s=80&d=identicon',
web_url: 'http://localhost:3001/treva.lesch',
},
assignee: null,
user_notes_count: 0,
blocking_issues_count: 0,
merge_requests_count: 0,
upvotes: 0,
downvotes: 0,
due_date: null,
confidential: false,
web_url: 'http://localhost:3001/h5bp/html5-boilerplate/issues/31',
has_tasks: false,
weight: null,
references: {
relative: 'html-boilerplate#45',
},
health_status: 'on_track',
};
export const testLabels = [
{
id: 1,
name: 'Tanuki',
description: 'A cute animal',
color: '#ff0000',
text_color: '#ffffff',
},
{
id: 2,
name: 'Octocat',
description: 'A grotesque mish-mash of whiskers and tentacles',
color: '#333333',
text_color: '#000000',
},
{
id: 3,
name: 'scoped::label',
description: 'A scoped label',
color: '#00ff00',
text_color: '#ffffff',
},
];
export const testAssignees = [
{
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
web_url: 'http://localhost:3001/root',
},
{
id: 22,
name: 'User 0',
username: 'user0',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80&d=identicon',
web_url: 'http://localhost:3001/user0',
},
];
import { emptyStateHelper, generateMessages } from '~/issues_list/service_desk_helper';
describe('service desk helper', () => {
const emptyStateMessages = generateMessages({});
// Note: isServiceDeskEnabled must not be true when isServiceDeskSupported is false (it's an invalid case).
describe.each`
isServiceDeskSupported | isServiceDeskEnabled | canEditProjectSettings | expectedMessage
${true} | ${true} | ${true} | ${'serviceDeskEnabledAndCanEditProjectSettings'}
${true} | ${true} | ${false} | ${'serviceDeskEnabledAndCannotEditProjectSettings'}
${true} | ${false} | ${true} | ${'serviceDeskDisabledAndCanEditProjectSettings'}
${true} | ${false} | ${false} | ${'serviceDeskDisabledAndCannotEditProjectSettings'}
${false} | ${false} | ${true} | ${'serviceDeskIsNotSupported'}
${false} | ${false} | ${false} | ${'serviceDeskIsNotEnabled'}
`(
'isServiceDeskSupported = $isServiceDeskSupported, isServiceDeskEnabled = $isServiceDeskEnabled, canEditProjectSettings = $canEditProjectSettings',
({ isServiceDeskSupported, isServiceDeskEnabled, canEditProjectSettings, expectedMessage }) => {
it(`displays ${expectedMessage} message`, () => {
const emptyStateMeta = {
isServiceDeskEnabled,
isServiceDeskSupported,
canEditProjectSettings,
};
expect(emptyStateHelper(emptyStateMeta)).toEqual(emptyStateMessages[expectedMessage]);
});
},
);
});
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issues_list/constants';
import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/jira_import/utils/constants';
import {
calculateJiraImportLabel,
extractJiraProjectsOptions,
......
......@@ -246,27 +246,6 @@ RSpec.describe IssuesHelper do
end
end
describe '#use_startup_call' do
it 'returns false when a query param is present' do
allow(controller.request).to receive(:query_parameters).and_return({ foo: 'bar' })
expect(helper.use_startup_call?).to eq(false)
end
it 'returns false when user has stored sort preference' do
controller.instance_variable_set(:@sort, 'updated_asc')
expect(helper.use_startup_call?).to eq(false)
end
it 'returns true when request.query_parameters is empty with default sorting preference' do
controller.instance_variable_set(:@sort, 'created_date')
allow(controller.request).to receive(:query_parameters).and_return({})
expect(helper.use_startup_call?).to eq(true)
end
end
describe '#issue_header_actions_data' do
let(:current_user) { create(:user) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Issues::ServiceDeskHelper do
let_it_be(:project) { create(:project, :public, service_desk_enabled: true) }
let(:user) { build_stubbed(:user) }
let(:current_user) { user }
describe '#service_desk_meta' do
subject { helper.service_desk_meta(project) }
context "when service desk is supported and user can edit project settings" do
before do
allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true)
allow(helper).to receive(:current_user).and_return(user)
allow(helper).to receive(:can?).with(current_user, :admin_project, project).and_return(true)
end
it {
is_expected.to eq({
is_service_desk_supported: true,
is_service_desk_enabled: true,
can_edit_project_settings: true,
service_desk_address: project.service_desk_address,
service_desk_help_page: help_page_path('user/project/service_desk'),
edit_project_page: edit_project_path(project),
svg_path: ActionController::Base.helpers.image_path('illustrations/service_desk_empty.svg')
})
}
end
context "when service desk is not supported and user cannot edit project settings" do
before do
allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(false)
allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(false)
allow(helper).to receive(:current_user).and_return(user)
allow(helper).to receive(:can?).with(current_user, :admin_project, project).and_return(false)
end
it {
is_expected.to eq({
is_service_desk_supported: false,
is_service_desk_enabled: false,
can_edit_project_settings: false,
incoming_email_help_page: help_page_path('administration/incoming_email', anchor: 'set-it-up'),
svg_path: ActionController::Base.helpers.image_path('illustrations/service-desk-setup.svg')
})
}
end
end
end
......@@ -203,28 +203,80 @@ RSpec.describe Ci::Runner do
end
end
describe '.belonging_to_parent_group_of_project' do
let(:project) { create(:project, group: group) }
let(:group) { create(:group) }
let(:runner) { create(:ci_runner, :group, groups: [group]) }
let!(:unrelated_group) { create(:group) }
let!(:unrelated_project) { create(:project, group: unrelated_group) }
let!(:unrelated_runner) { create(:ci_runner, :group, groups: [unrelated_group]) }
shared_examples '.belonging_to_parent_group_of_project' do
let!(:group1) { create(:group) }
let!(:project1) { create(:project, group: group1) }
let!(:runner1) { create(:ci_runner, :group, groups: [group1]) }
let!(:group2) { create(:group) }
let!(:project2) { create(:project, group: group2) }
let!(:runner2) { create(:ci_runner, :group, groups: [group2]) }
let(:project_id) { project1.id }
subject(:result) { described_class.belonging_to_parent_group_of_project(project_id) }
it 'returns the specific group runner' do
expect(described_class.belonging_to_parent_group_of_project(project.id)).to contain_exactly(runner)
expect(result).to contain_exactly(runner1)
end
context 'with a parent group with a runner' do
let(:runner) { create(:ci_runner, :group, groups: [parent_group]) }
let(:project) { create(:project, group: group) }
let(:group) { create(:group, parent: parent_group) }
let(:parent_group) { create(:group) }
context 'with a parent group with a runner', :sidekiq_inline do
before do
group1.update!(parent: group2)
end
it 'returns the group runner from the parent group' do
expect(described_class.belonging_to_parent_group_of_project(project.id)).to contain_exactly(runner)
it 'returns the group runner from the group and the parent group' do
expect(result).to contain_exactly(runner1, runner2)
end
end
context 'with multiple project ids' do
let(:project_id) { [project1.id, project2.id] }
it 'raises ArgumentError' do
expect { result }.to raise_error(ArgumentError)
end
end
end
context 'when ci_decompose_belonging_to_parent_group_of_project_query is enabled' do
context 'when use_traversal_ids* are enabled' do
it_behaves_like '.belonging_to_parent_group_of_project'
end
context 'when use_traversal_ids* are disabled' do
before do
stub_feature_flags(
use_traversal_ids: false,
use_traversal_ids_for_ancestors: false,
use_traversal_ids_for_ancestor_scopes: false
)
end
it_behaves_like '.belonging_to_parent_group_of_project'
end
end
context 'when ci_decompose_belonging_to_parent_group_of_project_query is disabled' do
before do
stub_feature_flags(ci_decompose_belonging_to_parent_group_of_project_query: false)
end
context 'when use_traversal_ids* are enabled' do
it_behaves_like '.belonging_to_parent_group_of_project'
end
context 'when use_traversal_ids* are disabled' do
before do
stub_feature_flags(
use_traversal_ids: false,
use_traversal_ids_for_ancestors: false,
use_traversal_ids_for_ancestor_scopes: false
)
end
it_behaves_like '.belonging_to_parent_group_of_project'
end
end
describe '.owned_or_instance_wide' do
......
......@@ -202,7 +202,7 @@ RSpec.describe PipelineSerializer do
# Existing numbers are high and require performance optimization
# Ongoing issue:
# https://gitlab.com/gitlab-org/gitlab/-/issues/225156
expected_queries = Gitlab.ee? ? 74 : 70
expected_queries = Gitlab.ee? ? 78 : 74
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)
......
......@@ -290,10 +290,9 @@ RSpec.configure do |config|
stub_feature_flags(diffs_virtual_scrolling: false)
# The following `vue_issues_list`/`vue_issuables_list` stubs can be removed
# The following `vue_issues_list` stub can be removed
# once the Vue issues page has feature parity with the current Haml page
stub_feature_flags(vue_issues_list: false)
stub_feature_flags(vue_issuables_list: false)
# Disable `refactor_blob_viewer` as we refactor
# the blob viewer. See the follwing epic for more:
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment