Commit 398fef1c authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'mh/vue-issuables-list-ee' into 'master'

Refactor issuables list to Vue, part 1

See merge request gitlab-org/gitlab!15091
parents 5d94e9d2 693f99eb
/* 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"`;
This diff is collapsed.
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%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment