提交 cf85de26 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 bcdcff74
/* 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);
}
......
<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>
<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>
// 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 },
};
import Vue from 'vue';
const issueablesEventBus = new Vue();
export default issueablesEventBus;
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;
......
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,
......
<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'
-# 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")}
......
// 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"`;
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 }]]);
});
});
});
});
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();
});
});
});
});
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',
},
];
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();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.maxVisibleAssignees).toBe(2);
expect(vm.maxAssigneeAvatars).toBe(3);
expect(vm.maxAssignees).toBe(99);
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);
});
describe('computed', () => {
describe('countOverLimit', () => {
it('should return difference between assignees count and maxVisibleAssignees', () => {
expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees);
});
});
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);
});
});
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.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('assigneeCounterLabel', () => {
it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => {
expect(vm.assigneeCounterLabel).toBe('+3');
if (expectedShown) {
it('shows assignee avatars', () => {
expect(findAvatars().length).toEqual(expectedShown);
});
} else {
it('does not show assignee avatars', () => {
expect(findAvatars().length).toEqual(0);
});
}
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('methods', () => {
describe('avatarUrlTitle', () => {
it('returns string containing alt text for assignee avatar', () => {
expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
});
it('computes 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;
expect(tooltipText).toContain('Assignee');
expect(tooltipText).toContain('Terrell Graham');
expect(tooltipText).toContain('@monserrate.gleichner');
});
describe('assignee tooltips', () => {
it('renders "Assignee" header', () => {
expect(findTooltipText()).toContain('Assignee');
});
it('renders additional assignees count', () => {
const avatarCounterEl = vm.$el.querySelector('.avatar-counter');
it('renders assignee name', () => {
expect(findTooltipText()).toContain('Terrell Graham');
});
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');
});
});
});
});
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册