Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2019-11-06 21:06:44 +00:00
parent bcdcff7495
commit cf85de264d
21 changed files with 1697 additions and 119 deletions

View File

@ -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);
}

View File

@ -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">
&middot;
<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>

View File

@ -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>

View File

@ -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 },
};

View File

@ -0,0 +1,5 @@
import Vue from 'vue';
const issueablesEventBus = new Vue();
export default issueablesEventBus;

View File

@ -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 });
},
});
});
}

View File

@ -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';

View File

@ -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;

View File

@ -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,

View File

@ -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"

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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 ""

View File

@ -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

View File

@ -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")}

View File

@ -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"`;

View File

@ -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 }]]);
});
});
});
});

View File

@ -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();
});
});
});
});

View File

@ -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',
},
];

View File

@ -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');
});
});
});
});