Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
bcdcff7495
commit
cf85de264d
|
@ -1,11 +1,13 @@
|
|||
/* eslint-disable class-methods-use-this, no-new */
|
||||
|
||||
import $ from 'jquery';
|
||||
import { property } from 'underscore';
|
||||
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
|
||||
import MilestoneSelect from './milestone_select';
|
||||
import issueStatusSelect from './issue_status_select';
|
||||
import subscriptionSelect from './subscription_select';
|
||||
import LabelsSelect from './labels_select';
|
||||
import issueableEventHub from './issuables_list/eventhub';
|
||||
|
||||
const HIDDEN_CLASS = 'hidden';
|
||||
const DISABLED_CONTENT_CLASS = 'disabled-content';
|
||||
|
@ -14,6 +16,8 @@ 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();
|
||||
|
@ -41,6 +45,17 @@ export default class IssuableBulkUpdateSidebar {
|
|||
this.$issuesList.on('change', () => this.updateFormState());
|
||||
this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
|
||||
this.$checkAllContainer.on('click', () => this.updateFormState());
|
||||
|
||||
if (this.vueIssuablesListFeature) {
|
||||
issueableEventHub.$on('issuables:updateBulkEdit', () => {
|
||||
// Danger! Strong coupling ahead!
|
||||
// The bulk update sidebar and its dropdowns look for .selected-issuable checkboxes, and get data on which issue
|
||||
// is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties
|
||||
// explicitly, but this component is used in too many places right now to refactor straight away.
|
||||
|
||||
this.updateFormState();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initDropdowns() {
|
||||
|
@ -73,6 +88,8 @@ export default class IssuableBulkUpdateSidebar {
|
|||
toggleBulkEdit(e, enable) {
|
||||
e.preventDefault();
|
||||
|
||||
issueableEventHub.$emit('issuables:toggleBulkEdit', enable);
|
||||
|
||||
this.toggleSidebarDisplay(enable);
|
||||
this.toggleBulkEditButtonDisabled(enable);
|
||||
this.toggleOtherFiltersDisabled(enable);
|
||||
|
@ -106,7 +123,7 @@ export default class IssuableBulkUpdateSidebar {
|
|||
}
|
||||
|
||||
toggleCheckboxDisplay(show) {
|
||||
this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show);
|
||||
this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show || this.vueIssuablesListFeature);
|
||||
this.$issueChecks.toggleClass(HIDDEN_CLASS, !show);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,327 @@
|
|||
<script>
|
||||
/*
|
||||
* This is tightly coupled to projects/issues/_issue.html.haml,
|
||||
* any changes done to the haml need to be reflected here.
|
||||
*/
|
||||
import { escape, isNumber } from 'underscore';
|
||||
import { GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
|
||||
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 Icon from '~/vue_shared/components/icon.vue';
|
||||
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
|
||||
|
||||
const ISSUE_TOKEN = '#';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon,
|
||||
IssueAssignees,
|
||||
GlLink,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip,
|
||||
},
|
||||
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;
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
hasLabels() {
|
||||
return Boolean(this.issuable.labels && this.issuable.labels.length);
|
||||
},
|
||||
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;
|
||||
},
|
||||
hasNoComments() {
|
||||
return !this.userNotesCount;
|
||||
},
|
||||
isOverdue() {
|
||||
return this.dueDate ? this.dueDate < new Date() : false;
|
||||
},
|
||||
isClosed() {
|
||||
return this.issuable.state === 'closed';
|
||||
},
|
||||
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');
|
||||
},
|
||||
openedAgoByString() {
|
||||
const { author, created_at } = this.issuable;
|
||||
|
||||
return sprintf(
|
||||
__('opened %{timeAgoString} by %{user}'),
|
||||
{
|
||||
timeAgoString: escape(getTimeago().format(created_at)),
|
||||
user: `<a href="${escape(author.web_url)}"
|
||||
data-user-id=${escape(author.id)}
|
||||
data-username=${escape(author.username)}
|
||||
data-name=${escape(author.name)}
|
||||
data-avatar-url="${escape(author.avatar_url)}">
|
||||
${escape(author.name)}
|
||||
</a>`,
|
||||
},
|
||||
false,
|
||||
);
|
||||
},
|
||||
referencePath() {
|
||||
// TODO: The API should return the reference path (it doesn't now) https://gitlab.com/gitlab-org/gitlab/issues/31301
|
||||
return `${ISSUE_TOKEN}${this.issuable.iid}`;
|
||||
},
|
||||
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)),
|
||||
});
|
||||
},
|
||||
userNotesCount() {
|
||||
return this.issuable.user_notes_count;
|
||||
},
|
||||
issuableMeta() {
|
||||
return [
|
||||
{
|
||||
key: 'merge-requests',
|
||||
value: this.issuable.merge_requests_count,
|
||||
title: __('Related merge requests'),
|
||||
class: 'js-merge-requests',
|
||||
icon: 'merge-request',
|
||||
},
|
||||
{
|
||||
key: 'upvotes',
|
||||
value: this.issuable.upvotes,
|
||||
title: __('Upvotes'),
|
||||
class: 'js-upvotes',
|
||||
faicon: 'fa-thumbs-up',
|
||||
},
|
||||
{
|
||||
key: 'downvotes',
|
||||
value: this.issuable.downvotes,
|
||||
title: __('Downvotes'),
|
||||
class: 'js-downvotes',
|
||||
faicon: 'fa-thumbs-down',
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// TODO: Refactor user popover to use its own component instead of
|
||||
// spawning event listeners on Vue-rendered elements.
|
||||
initUserPopovers([this.$refs.openedAgoByContainer.querySelector('a')]);
|
||||
},
|
||||
methods: {
|
||||
labelStyle(label) {
|
||||
return {
|
||||
backgroundColor: label.color,
|
||||
color: label.text_color,
|
||||
};
|
||||
},
|
||||
labelHref({ name }) {
|
||||
return mergeUrlParams({ 'label_name[]': name }, this.baseUrl);
|
||||
},
|
||||
onSelect(ev) {
|
||||
this.$emit('select', {
|
||||
issuable: this.issuable,
|
||||
selected: ev.target.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
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"
|
||||
>
|
||||
<div class="d-flex">
|
||||
<!-- Bulk edit checkbox -->
|
||||
<div v-if="isBulkEditing" class="mr-2">
|
||||
<input
|
||||
:checked="selected"
|
||||
class="selected-issuable"
|
||||
type="checkbox"
|
||||
:data-id="issuable.id"
|
||||
@input="onSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Issuable info container -->
|
||||
<!-- Issuable main info -->
|
||||
<div class="flex-grow-1">
|
||||
<div class="title">
|
||||
<span class="issue-title-text">
|
||||
<i
|
||||
v-if="issuable.confidential"
|
||||
v-gl-tooltip
|
||||
class="fa fa-eye-slash"
|
||||
:title="$options.confidentialTooltipText"
|
||||
:aria-label="$options.confidentialTooltipText"
|
||||
></i>
|
||||
<gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link>
|
||||
</span>
|
||||
<span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block">
|
||||
{{ issuable.task_status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="issuable-info">
|
||||
<span>{{ referencePath }}</span>
|
||||
|
||||
<span class="d-none d-sm-inline-block mr-1">
|
||||
·
|
||||
<span ref="openedAgoByContainer" v-html="openedAgoByString"></span>
|
||||
</span>
|
||||
|
||||
<gl-link
|
||||
v-if="issuable.milestone"
|
||||
v-gl-tooltip
|
||||
class="d-none d-sm-inline-block mr-1 js-milestone"
|
||||
:href="issuable.milestone.web_url"
|
||||
:title="milestoneTooltipText"
|
||||
>
|
||||
<i class="fa fa-clock-o"></i>
|
||||
{{ issuable.milestone.title }}
|
||||
</gl-link>
|
||||
|
||||
<span
|
||||
v-if="dueDate"
|
||||
v-gl-tooltip
|
||||
class="d-none d-sm-inline-block mr-1 js-due-date"
|
||||
:class="{ cred: isOverdue }"
|
||||
:title="__('Due date')"
|
||||
>
|
||||
<i class="fa fa-calendar"></i>
|
||||
{{ dueDateWords }}
|
||||
</span>
|
||||
|
||||
<span v-if="hasLabels" class="js-labels">
|
||||
<gl-link
|
||||
v-for="label in issuable.labels"
|
||||
:key="label.id"
|
||||
class="label-link mr-1"
|
||||
:href="labelHref(label)"
|
||||
>
|
||||
<span
|
||||
v-gl-tooltip
|
||||
class="badge color-label"
|
||||
:style="labelStyle(label)"
|
||||
:title="label.description"
|
||||
>{{ label.name }}</span
|
||||
>
|
||||
</gl-link>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="hasWeight"
|
||||
v-gl-tooltip
|
||||
:title="__('Weight')"
|
||||
class="d-none d-sm-inline-block js-weight"
|
||||
>
|
||||
<icon name="weight" class="align-text-bottom" />
|
||||
{{ issuable.weight }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Issuable meta -->
|
||||
<div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center">
|
||||
<div class="controls d-flex">
|
||||
<span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span>
|
||||
|
||||
<issue-assignees
|
||||
:assignees="issuable.assignees"
|
||||
class="align-items-center d-flex ml-2"
|
||||
:icon-size="16"
|
||||
img-css-classes="mr-1"
|
||||
:max-visible="4"
|
||||
/>
|
||||
|
||||
<template v-for="meta in issuableMeta">
|
||||
<span
|
||||
v-if="meta.value"
|
||||
:key="meta.key"
|
||||
v-gl-tooltip
|
||||
:class="['d-none d-sm-inline-block ml-2', meta.class]"
|
||||
:title="meta.title"
|
||||
>
|
||||
<icon v-if="meta.icon" :name="meta.icon" />
|
||||
<i v-else :class="['fa', meta.faicon]"></i>
|
||||
{{ meta.value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<gl-link
|
||||
v-gl-tooltip
|
||||
class="ml-2 js-notes"
|
||||
:href="`${issuable.web_url}#notes`"
|
||||
:title="__('Comments')"
|
||||
:class="{ 'no-comments': hasNoComments }"
|
||||
>
|
||||
<i class="fa fa-comments"></i>
|
||||
{{ userNotesCount }}
|
||||
</gl-link>
|
||||
</div>
|
||||
<div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString">
|
||||
{{ updatedDateAgo }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
|
@ -0,0 +1,277 @@
|
|||
<script>
|
||||
import { omit } from 'underscore';
|
||||
import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui';
|
||||
import flash from '~/flash';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { scrollToElement, urlParamsToObject } from '~/lib/utils/common_utils';
|
||||
import { __ } from '~/locale';
|
||||
import initManualOrdering from '~/manual_ordering';
|
||||
import Issuable from './issuable.vue';
|
||||
import {
|
||||
sortOrderMap,
|
||||
RELATIVE_POSITION,
|
||||
PAGE_SIZE,
|
||||
PAGE_SIZE_MANUAL,
|
||||
LOADING_LIST_ITEMS_LENGTH,
|
||||
} from '../constants';
|
||||
import issueableEventHub from '../eventhub';
|
||||
|
||||
export default {
|
||||
LOADING_LIST_ITEMS_LENGTH,
|
||||
components: {
|
||||
GlEmptyState,
|
||||
GlPagination,
|
||||
GlSkeletonLoading,
|
||||
Issuable,
|
||||
},
|
||||
props: {
|
||||
canBulkEdit: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
createIssuePath: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
emptySvgPath: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
endpoint: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
sortKey: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
filters: {},
|
||||
isBulkEditing: false,
|
||||
issuables: [],
|
||||
loading: false,
|
||||
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
|
||||
} else if (this.hasFilters) {
|
||||
return {
|
||||
title: __('Sorry, your filter produced no results'),
|
||||
description: __('To widen your search, change or remove filters above'),
|
||||
};
|
||||
} else if (this.filters.state === 'opened') {
|
||||
return {
|
||||
title: __('There are no open issues'),
|
||||
description: __('To keep this project going, create a new issue'),
|
||||
primaryLink: this.createIssuePath,
|
||||
primaryText: __('New issue'),
|
||||
};
|
||||
} else if (this.filters.state === 'closed') {
|
||||
return {
|
||||
title: __('There are no closed issues'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: __('There are no issues to show'),
|
||||
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(/(\?.*)?(#.*)?$/, '');
|
||||
},
|
||||
},
|
||||
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');
|
||||
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,
|
||||
...filters
|
||||
} = this.getQueryObject();
|
||||
|
||||
if (milestoneTitle) {
|
||||
filters.milestone = milestoneTitle;
|
||||
}
|
||||
if (Array.isArray(labels)) {
|
||||
filters.labels = labels.join(',');
|
||||
}
|
||||
if (!filters.state) {
|
||||
filters.state = 'opened';
|
||||
}
|
||||
|
||||
Object.assign(filters, sortOrderMap[this.sortKey]);
|
||||
|
||||
this.filters = filters;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul v-if="loading" class="content-list">
|
||||
<li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue">
|
||||
<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 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-if="totalItems"
|
||||
:value="page"
|
||||
:per-page="itemsPerPage"
|
||||
:total-items="totalItems"
|
||||
class="justify-content-center"
|
||||
@input="onPaginate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<gl-empty-state
|
||||
v-else
|
||||
:title="emptyState.title"
|
||||
:description="emptyState.description"
|
||||
:svg-path="emptySvgPath"
|
||||
:primary-button-link="emptyState.primaryLink"
|
||||
:primary-button-text="emptyState.primaryText"
|
||||
/>
|
||||
</template>
|
|
@ -0,0 +1,33 @@
|
|||
// 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 },
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
const issueablesEventBus = new Vue();
|
||||
|
||||
export default issueablesEventBus;
|
|
@ -0,0 +1,24 @@
|
|||
import Vue from 'vue';
|
||||
import IssuablesListApp from './components/issuables_list_app.vue';
|
||||
|
||||
export default function initIssuablesList() {
|
||||
if (!gon.features || !gon.features.vueIssuablesList) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.js-issuables-list').forEach(el => {
|
||||
const { canBulkEdit, ...data } = el.dataset;
|
||||
|
||||
const props = {
|
||||
...data,
|
||||
canBulkEdit: Boolean(canBulkEdit),
|
||||
};
|
||||
|
||||
return new Vue({
|
||||
el,
|
||||
render(createElement) {
|
||||
return createElement(IssuablesListApp, { props });
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
|
@ -78,11 +78,11 @@ export const getDayName = date =>
|
|||
* @param {date} datetime
|
||||
* @returns {String}
|
||||
*/
|
||||
export const formatDate = datetime => {
|
||||
export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => {
|
||||
if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
|
||||
throw new Error(__('Invalid date'));
|
||||
}
|
||||
return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
|
||||
return dateFormat(datetime, format);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -558,6 +558,17 @@ export const calculateRemainingMilliseconds = endDate => {
|
|||
export const getDateInPast = (date, daysInPast) =>
|
||||
new Date(newDate(date).setDate(date.getDate() - daysInPast));
|
||||
|
||||
/*
|
||||
* Appending T00:00:00 makes JS assume local time and prevents it from shifting the date
|
||||
* to match the user's time zone. We want to display the date in server time for now, to
|
||||
* be consistent with the "edit issue -> due date" UI.
|
||||
*/
|
||||
|
||||
export const newDateAsLocaleTime = date => {
|
||||
const suffix = 'T00:00:00';
|
||||
return new Date(`${date}${suffix}`);
|
||||
};
|
||||
|
||||
export const beginOfDayTime = 'T00:00:00Z';
|
||||
export const endOfDayTime = 'T23:59:59Z';
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) =>
|
|||
createFlash(s__("ManualOrdering|Couldn't save the order of the issues"));
|
||||
});
|
||||
|
||||
const initManualOrdering = () => {
|
||||
const initManualOrdering = (draggableSelector = 'li.issue') => {
|
||||
const issueList = document.querySelector('.manual-ordering');
|
||||
|
||||
if (!issueList || !(gon.current_user_id > 0)) {
|
||||
|
@ -34,14 +34,14 @@ const initManualOrdering = () => {
|
|||
group: {
|
||||
name: 'issues',
|
||||
},
|
||||
draggable: 'li.issue',
|
||||
draggable: draggableSelector,
|
||||
onStart: () => {
|
||||
sortableStart();
|
||||
},
|
||||
onUpdate: event => {
|
||||
const el = event.item;
|
||||
|
||||
const url = el.getAttribute('url');
|
||||
const url = el.getAttribute('url') || el.dataset.url;
|
||||
|
||||
const prev = el.previousElementSibling;
|
||||
const next = el.nextElementSibling;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import initIssuablesList from '~/issuables_list';
|
||||
import projectSelect from '~/project_select';
|
||||
import initFilteredSearch from '~/pages/search/init_filtered_search';
|
||||
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
|
||||
|
@ -11,6 +12,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
|
||||
issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
|
||||
|
||||
initIssuablesList();
|
||||
|
||||
initFilteredSearch({
|
||||
page: FILTERED_SEARCH.ISSUES,
|
||||
isGroupDecendent: true,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<script>
|
||||
import { GlTooltipDirective } from '@gitlab/ui';
|
||||
import { __, sprintf } from '~/locale';
|
||||
|
||||
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
|
||||
export default {
|
||||
|
@ -16,44 +15,47 @@ export default {
|
|||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
iconSize: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 24,
|
||||
},
|
||||
imgCssClasses: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
maxVisible: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 3,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
maxVisibleAssignees: 2,
|
||||
maxAssigneeAvatars: 3,
|
||||
maxAssignees: 99,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
countOverLimit() {
|
||||
return this.assignees.length - this.maxVisibleAssignees;
|
||||
},
|
||||
assigneesToShow() {
|
||||
if (this.assignees.length > this.maxAssigneeAvatars) {
|
||||
return this.assignees.slice(0, this.maxVisibleAssignees);
|
||||
}
|
||||
return this.assignees;
|
||||
const numShownAssignees = this.assignees.length - this.numHiddenAssignees;
|
||||
return this.assignees.slice(0, numShownAssignees);
|
||||
},
|
||||
assigneesCounterTooltip() {
|
||||
const { countOverLimit, maxAssignees } = this;
|
||||
const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit;
|
||||
|
||||
return sprintf(__('%{count} more assignees'), { count });
|
||||
return sprintf(__('%{count} more assignees'), { count: this.numHiddenAssignees });
|
||||
},
|
||||
shouldRenderAssigneesCounter() {
|
||||
const assigneesCount = this.assignees.length;
|
||||
if (assigneesCount <= this.maxAssigneeAvatars) {
|
||||
return false;
|
||||
numHiddenAssignees() {
|
||||
if (this.assignees.length > this.maxVisible) {
|
||||
return this.assignees.length - this.maxVisible + 1;
|
||||
}
|
||||
|
||||
return assigneesCount > this.countOverLimit;
|
||||
return 0;
|
||||
},
|
||||
assigneeCounterLabel() {
|
||||
if (this.countOverLimit > this.maxAssignees) {
|
||||
if (this.numHiddenAssignees > this.maxAssignees) {
|
||||
return `${this.maxAssignees}+`;
|
||||
}
|
||||
|
||||
return `+${this.countOverLimit}`;
|
||||
return `+${this.numHiddenAssignees}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -81,8 +83,9 @@ export default {
|
|||
:key="assignee.id"
|
||||
:link-href="webUrl(assignee)"
|
||||
:img-alt="avatarUrlTitle(assignee)"
|
||||
:img-css-classes="imgCssClasses"
|
||||
:img-src="avatarUrl(assignee)"
|
||||
:img-size="24"
|
||||
:img-size="iconSize"
|
||||
class="js-no-trigger"
|
||||
tooltip-placement="bottom"
|
||||
>
|
||||
|
@ -92,7 +95,7 @@ export default {
|
|||
</span>
|
||||
</user-avatar-link>
|
||||
<span
|
||||
v-if="shouldRenderAssigneesCounter"
|
||||
v-if="numHiddenAssignees > 0"
|
||||
v-gl-tooltip
|
||||
:title="assigneesCounterTooltip"
|
||||
class="avatar-counter"
|
||||
|
|
|
@ -25,6 +25,10 @@ class GroupsController < Groups::ApplicationController
|
|||
|
||||
before_action :user_actions, only: [:show]
|
||||
|
||||
before_action do
|
||||
push_frontend_feature_flag(:vue_issuables_list, @group)
|
||||
end
|
||||
|
||||
skip_cross_project_access_check :index, :new, :create, :edit, :update,
|
||||
:destroy, :projects
|
||||
# When loading show as an atom feed, we render events that could leak cross
|
||||
|
|
|
@ -22,4 +22,10 @@
|
|||
- if @can_bulk_update
|
||||
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
|
||||
|
||||
= render 'shared/issues'
|
||||
- if Feature.enabled?(:vue_issuables_list, @group)
|
||||
.js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
|
||||
'can-bulk-edit': @can_bulk_update.to_json,
|
||||
'empty-svg-path': image_path('illustrations/issues.svg'),
|
||||
'sort-key': @sort } }
|
||||
- else
|
||||
= render 'shared/issues'
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
-# 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 } }
|
||||
.issue-box
|
||||
- if @can_bulk_update
|
||||
|
|
|
@ -324,6 +324,9 @@ msgstr ""
|
|||
msgid "%{percent}%% complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{primary} (%{secondary})"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{releases} release"
|
||||
msgid_plural "%{releases} releases"
|
||||
msgstr[0] ""
|
||||
|
@ -1627,6 +1630,9 @@ msgstr ""
|
|||
msgid "An error occurred while loading filenames"
|
||||
msgstr ""
|
||||
|
||||
msgid "An error occurred while loading issues"
|
||||
msgstr ""
|
||||
|
||||
msgid "An error occurred while loading the file"
|
||||
msgstr ""
|
||||
|
||||
|
@ -20650,6 +20656,9 @@ msgstr ""
|
|||
msgid "nounSeries|%{item}, and %{lastItem}"
|
||||
msgstr ""
|
||||
|
||||
msgid "opened %{timeAgoString} by %{user}"
|
||||
msgstr ""
|
||||
|
||||
msgid "or %{link_start}create a new Google account%{link_end}"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -26,6 +26,10 @@ describe 'Explore Groups', :js do
|
|||
end
|
||||
end
|
||||
|
||||
before do
|
||||
stub_feature_flags({ vue_issuables_list: { enabled: false, thing: group } })
|
||||
end
|
||||
|
||||
shared_examples 'renders public and internal projects' do
|
||||
it do
|
||||
visit_page
|
||||
|
|
|
@ -11,6 +11,10 @@ describe 'Group issues page' do
|
|||
let(:project_with_issues_disabled) { create(:project, :issues_disabled, group: group) }
|
||||
let(:path) { issues_group_path(group) }
|
||||
|
||||
before do
|
||||
stub_feature_flags({ vue_issuables_list: { enabled: false, thing: group } })
|
||||
end
|
||||
|
||||
context 'with shared examples' do
|
||||
let(:issuable) { create(:issue, project: project, title: "this is my created issuable")}
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
// 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`] = `
|
||||
<glemptystate-stub
|
||||
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."
|
||||
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"`;
|
|
@ -0,0 +1,338 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlLink } from '@gitlab/ui';
|
||||
import { TEST_HOST } from 'helpers/test_constants';
|
||||
import { trimText } from 'helpers/text_helper';
|
||||
import initUserPopovers from '~/user_popovers';
|
||||
import { formatDate } from '~/lib/utils/datetime_utility';
|
||||
import { mergeUrlParams } from '~/lib/utils/url_utility';
|
||||
import Issuable from '~/issuables_list/components/issuable.vue';
|
||||
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
|
||||
import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data';
|
||||
|
||||
jest.mock('~/user_popovers');
|
||||
|
||||
const TEST_NOW = '2019-08-28T20:03:04.713Z';
|
||||
const TEST_MONTH_AGO = '2019-07-28';
|
||||
const TEST_MONTH_LATER = '2019-09-30';
|
||||
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;
|
||||
|
||||
// Use FixedDate so that time sensitive info in snapshots don't fail
|
||||
class FixedDate extends Date {
|
||||
constructor(date = TEST_NOW) {
|
||||
super(date);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Issuable component', () => {
|
||||
let issuable;
|
||||
let DateOrig;
|
||||
let wrapper;
|
||||
|
||||
const factory = (props = {}) => {
|
||||
wrapper = shallowMount(Issuable, {
|
||||
propsData: {
|
||||
issuable: simpleIssue,
|
||||
baseUrl: TEST_BASE_URL,
|
||||
...props,
|
||||
},
|
||||
sync: false,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
issuable = { ...simpleIssue };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
DateOrig = window.Date;
|
||||
window.Date = FixedDate;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
window.Date = DateOrig;
|
||||
});
|
||||
|
||||
const findConfidentialIcon = () => wrapper.find('.fa-eye-slash');
|
||||
const findTaskStatus = () => wrapper.find('.task-status');
|
||||
const findOpenedAgoContainer = () => wrapper.find({ ref: 'openedAgoByContainer' });
|
||||
const findMilestone = () => wrapper.find('.js-milestone');
|
||||
const findMilestoneTooltip = () => findMilestone().attributes('data-original-title');
|
||||
const findDueDate = () => wrapper.find('.js-due-date');
|
||||
const findLabelContainer = () => wrapper.find('.js-labels');
|
||||
const findLabelLinks = () => findLabelContainer().findAll(GlLink);
|
||||
const findWeight = () => wrapper.find('.js-weight');
|
||||
const findAssignees = () => wrapper.find(IssueAssignees);
|
||||
const findMergeRequestsCount = () => wrapper.find('.js-merge-requests');
|
||||
const findUpvotes = () => wrapper.find('.js-upvotes');
|
||||
const findDownvotes = () => wrapper.find('.js-downvotes');
|
||||
const findNotes = () => wrapper.find('.js-notes');
|
||||
const findBulkCheckbox = () => wrapper.find('input.selected-issuable');
|
||||
|
||||
describe('when mounted', () => {
|
||||
it('initializes user popovers', () => {
|
||||
expect(initUserPopovers).not.toHaveBeenCalled();
|
||||
|
||||
factory();
|
||||
|
||||
expect(initUserPopovers).toHaveBeenCalledWith([findOpenedAgoContainer().find('a').element]);
|
||||
});
|
||||
});
|
||||
|
||||
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 | finder
|
||||
${'bulk editing checkbox'} | ${findBulkCheckbox}
|
||||
${'confidential icon'} | ${findConfidentialIcon}
|
||||
${'task status'} | ${findTaskStatus}
|
||||
${'milestone'} | ${findMilestone}
|
||||
${'due date'} | ${findDueDate}
|
||||
${'labels'} | ${findLabelContainer}
|
||||
${'weight'} | ${findWeight}
|
||||
${'merge request count'} | ${findMergeRequestsCount}
|
||||
${'upvotes'} | ${findUpvotes}
|
||||
${'downvotes'} | ${findDownvotes}
|
||||
`('does not render $desc', ({ finder }) => {
|
||||
expect(finder().exists()).toBe(false);
|
||||
});
|
||||
|
||||
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 opened date and author', () => {
|
||||
expect(trimText(findOpenedAgoContainer().text())).toEqual(
|
||||
`opened 1 month ago by ${TEST_USER_NAME}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders no comments', () => {
|
||||
expect(findNotes().classes('no-comments')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with confidential issuable', () => {
|
||||
beforeEach(() => {
|
||||
issuable.confidential = true;
|
||||
|
||||
factory({ issuable });
|
||||
});
|
||||
|
||||
it('renders the confidential icon', () => {
|
||||
expect(findConfidentialIcon().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
findMilestone()
|
||||
.find('.fa-clock-o')
|
||||
.exists(),
|
||||
).toBe(true);
|
||||
expect(findMilestone().text()).toEqual(TEST_MILESTONE.title);
|
||||
});
|
||||
|
||||
it('renders tooltip', () => {
|
||||
expect(findMilestoneTooltip()).toBe(
|
||||
`${formatDate(dueDate, DATE_FORMAT)} (${expectedTooltipPart})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 = findLabelLinks().wrappers.map(label => ({
|
||||
href: label.attributes('href'),
|
||||
text: label.text(),
|
||||
tooltip: label.find('span').attributes('data-original-title'),
|
||||
}));
|
||||
|
||||
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.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 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 merge requests 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');
|
||||
|
||||
expect(wrapper.emitted().select).toEqual([[{ issuable, selected: !selected }]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,410 @@
|
|||
import axios from 'axios';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui';
|
||||
import flash from '~/flash';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { TEST_HOST } from 'helpers/test_constants';
|
||||
import IssuablesListApp from '~/issuables_list/components/issuables_list_app.vue';
|
||||
import Issuable from '~/issuables_list/components/issuable.vue';
|
||||
import issueablesEventBus from '~/issuables_list/eventhub';
|
||||
import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issuables_list/constants';
|
||||
|
||||
jest.mock('~/flash', () => jest.fn());
|
||||
jest.mock('~/issuables_list/eventhub');
|
||||
|
||||
const TEST_LOCATION = `${TEST_HOST}/issues`;
|
||||
const TEST_ENDPOINT = '/issues';
|
||||
const TEST_CREATE_ISSUES_PATH = '/createIssue';
|
||||
const TEST_EMPTY_SVG_PATH = '/emptySvg';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
|
||||
const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
id: i,
|
||||
web_url: `url${i}`,
|
||||
}));
|
||||
|
||||
describe('Issuables list component', () => {
|
||||
let oldLocation;
|
||||
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' }) => {
|
||||
wrapper = shallowMount(localVue.extend(IssuablesListApp), {
|
||||
propsData: {
|
||||
endpoint: TEST_ENDPOINT,
|
||||
createIssuePath: TEST_CREATE_ISSUES_PATH,
|
||||
emptySvgPath: TEST_EMPTY_SVG_PATH,
|
||||
...props,
|
||||
},
|
||||
localVue,
|
||||
sync: false,
|
||||
});
|
||||
};
|
||||
|
||||
const findLoading = () => wrapper.find(GlSkeletonLoading);
|
||||
const findIssuables = () => wrapper.findAll(Issuable);
|
||||
const findFirstIssuable = () => findIssuables().wrappers[0];
|
||||
const findEmptyState = () => wrapper.find(GlEmptyState);
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxios = new MockAdapter(axios);
|
||||
|
||||
oldLocation = window.location;
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { href: '', search: '' },
|
||||
});
|
||||
window.location.href = TEST_LOCATION;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
mockAxios.restore();
|
||||
jest.clearAllMocks();
|
||||
window.location = oldLocation;
|
||||
});
|
||||
|
||||
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(flash).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,
|
||||
createIssuePath: TEST_CREATE_ISSUES_PATH,
|
||||
emptySvgPath: TEST_EMPTY_SVG_PATH,
|
||||
|
||||
// Data
|
||||
filters: {
|
||||
state: 'opened',
|
||||
},
|
||||
isBulkEditing: false,
|
||||
issuables: [],
|
||||
loading: true,
|
||||
page: 1,
|
||||
selection: {},
|
||||
totalItems: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call API until mounted', () => {
|
||||
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.contains(GlEmptyState)).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with bulk editing enabled', () => {
|
||||
beforeEach(() => {
|
||||
issueablesEventBus.$on.mockReset();
|
||||
issueablesEventBus.$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', () => {
|
||||
issueablesEventBus.$emit.mockReset();
|
||||
const i1 = wrapper.vm.issuables[1];
|
||||
|
||||
wrapper.vm.onSelectIssuable({ issuable: i1, selected: true });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(1);
|
||||
expect(issueablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not broadcast a message to the bulk edit sidebar when a value is not added to selection', () => {
|
||||
issueablesEventBus.$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(issueablesEventBus.$emit).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('listens to a message to toggle bulk editing', () => {
|
||||
expect(wrapper.vm.isBulkEditing).toBe(false);
|
||||
expect(issueablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit');
|
||||
issueablesEventBus.$on.mock.calls[0][1](true); // Call the message handler
|
||||
|
||||
return waitForPromises()
|
||||
.then(() => {
|
||||
expect(wrapper.vm.isBulkEditing).toBe(true);
|
||||
issueablesEventBus.$on.mock.calls[0][1](false);
|
||||
})
|
||||
.then(() => {
|
||||
expect(wrapper.vm.isBulkEditing).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with query params in window.location', () => {
|
||||
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&utf8=%E2%9C%93&weight=0';
|
||||
const expectedFilters = {
|
||||
assignee_username: 'root',
|
||||
author_username: 'root',
|
||||
confidential: 'yes',
|
||||
my_reaction_emoji: 'airplane',
|
||||
scope: 'all',
|
||||
state: 'opened',
|
||||
utf8: '✓',
|
||||
weight: '0',
|
||||
milestone: 'v3.0',
|
||||
labels: 'Aquapod,Astro',
|
||||
order_by: 'milestone_due',
|
||||
sort: 'desc',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.location.href = `${TEST_LOCATION}${query}`;
|
||||
window.location.search = query;
|
||||
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
|
||||
factory({ sortKey: 'milestone_due_desc' });
|
||||
return waitForPromises();
|
||||
});
|
||||
|
||||
it('applies filters and sorts', () => {
|
||||
expect(wrapper.vm.hasFilters).toBe(true);
|
||||
expect(wrapper.vm.filters).toEqual(expectedFilters);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: {
|
||||
...expectedFilters,
|
||||
with_labels_details: true,
|
||||
page: 1,
|
||||
per_page: PAGE_SIZE,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the base url to issuable', () => {
|
||||
expect(findFirstIssuable().props('baseUrl')).toEqual(TEST_LOCATION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with hash in window.location', () => {
|
||||
beforeEach(() => {
|
||||
window.location.href = `${TEST_LOCATION}#stuff`;
|
||||
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
|
||||
factory();
|
||||
return waitForPromises();
|
||||
});
|
||||
|
||||
it('passes the base url to issuable', () => {
|
||||
expect(findFirstIssuable().props('baseUrl')).toEqual(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(() => {
|
||||
window.location.search = '?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(() => {
|
||||
window.location.search = '?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(() => {
|
||||
window.location.search = '?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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,72 @@
|
|||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
|
@ -1,114 +1,129 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
|
||||
|
||||
import mountComponent from 'helpers/vue_mount_component_helper';
|
||||
import { mockAssigneesList } from '../../../../javascripts/boards/mock_data';
|
||||
|
||||
const createComponent = (assignees = mockAssigneesList, cssClass = '') => {
|
||||
const Component = Vue.extend(IssueAssignees);
|
||||
|
||||
return mountComponent(Component, {
|
||||
assignees,
|
||||
cssClass,
|
||||
});
|
||||
};
|
||||
const TEST_CSS_CLASSES = 'test-classes';
|
||||
const TEST_MAX_VISIBLE = 4;
|
||||
const TEST_ICON_SIZE = 16;
|
||||
|
||||
describe('IssueAssigneesComponent', () => {
|
||||
let wrapper;
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
const factory = props => {
|
||||
wrapper = shallowMount(IssueAssignees, {
|
||||
propsData: {
|
||||
assignees: mockAssigneesList,
|
||||
...props,
|
||||
},
|
||||
sync: false,
|
||||
});
|
||||
vm = wrapper.vm; // eslint-disable-line
|
||||
};
|
||||
|
||||
const findTooltipText = () => wrapper.find('.js-assignee-tooltip').text();
|
||||
const findAvatars = () => wrapper.findAll(UserAvatarLink);
|
||||
const findOverflowCounter = () => wrapper.find('.avatar-counter');
|
||||
|
||||
it('returns default data props', () => {
|
||||
factory({ assignees: mockAssigneesList });
|
||||
expect(vm.iconSize).toBe(24);
|
||||
expect(vm.maxVisible).toBe(3);
|
||||
expect(vm.maxAssignees).toBe(99);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
describe.each`
|
||||
numAssignees | maxVisible | expectedShown | expectedHidden
|
||||
${0} | ${3} | ${0} | ${''}
|
||||
${1} | ${3} | ${1} | ${''}
|
||||
${2} | ${3} | ${2} | ${''}
|
||||
${3} | ${3} | ${3} | ${''}
|
||||
${4} | ${3} | ${2} | ${'+2'}
|
||||
${5} | ${2} | ${1} | ${'+4'}
|
||||
${1000} | ${5} | ${4} | ${'99+'}
|
||||
`(
|
||||
'with assignees ($numAssignees) and maxVisible ($maxVisible)',
|
||||
({ numAssignees, maxVisible, expectedShown, expectedHidden }) => {
|
||||
beforeEach(() => {
|
||||
factory({ assignees: Array(numAssignees).fill({}), maxVisible });
|
||||
});
|
||||
|
||||
describe('data', () => {
|
||||
it('returns default data props', () => {
|
||||
expect(vm.maxVisibleAssignees).toBe(2);
|
||||
expect(vm.maxAssigneeAvatars).toBe(3);
|
||||
expect(vm.maxAssignees).toBe(99);
|
||||
});
|
||||
});
|
||||
if (expectedShown) {
|
||||
it('shows assignee avatars', () => {
|
||||
expect(findAvatars().length).toEqual(expectedShown);
|
||||
});
|
||||
} else {
|
||||
it('does not show assignee avatars', () => {
|
||||
expect(findAvatars().length).toEqual(0);
|
||||
});
|
||||
}
|
||||
|
||||
describe('computed', () => {
|
||||
describe('countOverLimit', () => {
|
||||
it('should return difference between assignees count and maxVisibleAssignees', () => {
|
||||
expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees);
|
||||
if (expectedHidden) {
|
||||
it('shows overflow counter', () => {
|
||||
const hiddenCount = numAssignees - expectedShown;
|
||||
|
||||
expect(findOverflowCounter().exists()).toBe(true);
|
||||
expect(findOverflowCounter().text()).toEqual(expectedHidden.toString());
|
||||
expect(findOverflowCounter().attributes('data-original-title')).toEqual(
|
||||
`${hiddenCount} more assignees`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
it('does not show overflow counter', () => {
|
||||
expect(findOverflowCounter().exists()).toBe(false);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe('when mounted', () => {
|
||||
beforeEach(() => {
|
||||
factory({
|
||||
imgCssClasses: TEST_CSS_CLASSES,
|
||||
maxVisible: TEST_MAX_VISIBLE,
|
||||
iconSize: TEST_ICON_SIZE,
|
||||
});
|
||||
});
|
||||
|
||||
describe('assigneesToShow', () => {
|
||||
it('should return assignees containing only 2 items when count more than maxAssigneeAvatars', () => {
|
||||
expect(vm.assigneesToShow.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return all assignees as it is when count less than maxAssigneeAvatars', () => {
|
||||
vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
|
||||
|
||||
expect(vm.assigneesToShow.length).toBe(3);
|
||||
});
|
||||
it('computes alt text for assignee avatar', () => {
|
||||
expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
|
||||
});
|
||||
|
||||
describe('assigneesCounterTooltip', () => {
|
||||
it('should return string containing count of remaining assignees when count more than maxAssigneeAvatars', () => {
|
||||
expect(vm.assigneesCounterTooltip).toBe('3 more assignees');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRenderAssigneesCounter', () => {
|
||||
it('should return `false` when assignees count less than maxAssigneeAvatars', () => {
|
||||
vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
|
||||
|
||||
expect(vm.shouldRenderAssigneesCounter).toBe(false);
|
||||
});
|
||||
|
||||
it('should return `true` when assignees count more than maxAssigneeAvatars', () => {
|
||||
expect(vm.shouldRenderAssigneesCounter).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assigneeCounterLabel', () => {
|
||||
it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => {
|
||||
expect(vm.assigneeCounterLabel).toBe('+3');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
describe('avatarUrlTitle', () => {
|
||||
it('returns string containing alt text for assignee avatar', () => {
|
||||
expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('renders component root element with class `issue-assignees`', () => {
|
||||
expect(vm.$el.classList.contains('issue-assignees')).toBe(true);
|
||||
expect(wrapper.element.classList.contains('issue-assignees')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders assignee avatars', () => {
|
||||
expect(vm.$el.querySelectorAll('.user-avatar-link').length).toBe(2);
|
||||
it('renders assignee', () => {
|
||||
const data = findAvatars().wrappers.map(x => ({
|
||||
...x.props(),
|
||||
}));
|
||||
|
||||
const expected = mockAssigneesList.slice(0, TEST_MAX_VISIBLE - 1).map(x =>
|
||||
expect.objectContaining({
|
||||
linkHref: x.web_url,
|
||||
imgAlt: `Avatar for ${x.name}`,
|
||||
imgCssClasses: TEST_CSS_CLASSES,
|
||||
imgSrc: x.avatar_url,
|
||||
imgSize: TEST_ICON_SIZE,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(data).toEqual(expected);
|
||||
});
|
||||
|
||||
it('renders assignee tooltips', () => {
|
||||
const tooltipText = vm.$el
|
||||
.querySelectorAll('.user-avatar-link')[0]
|
||||
.querySelector('.js-assignee-tooltip').innerText;
|
||||
describe('assignee tooltips', () => {
|
||||
it('renders "Assignee" header', () => {
|
||||
expect(findTooltipText()).toContain('Assignee');
|
||||
});
|
||||
|
||||
expect(tooltipText).toContain('Assignee');
|
||||
expect(tooltipText).toContain('Terrell Graham');
|
||||
expect(tooltipText).toContain('@monserrate.gleichner');
|
||||
});
|
||||
it('renders assignee name', () => {
|
||||
expect(findTooltipText()).toContain('Terrell Graham');
|
||||
});
|
||||
|
||||
it('renders additional assignees count', () => {
|
||||
const avatarCounterEl = vm.$el.querySelector('.avatar-counter');
|
||||
|
||||
expect(avatarCounterEl.innerText.trim()).toBe('+3');
|
||||
expect(avatarCounterEl.getAttribute('data-original-title')).toBe('3 more assignees');
|
||||
it('renders assignee @username', () => {
|
||||
expect(findTooltipText()).toContain('@monserrate.gleichner');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue