Commit 693f99eb authored by Martin Hanzel's avatar Martin Hanzel Committed by Paul Slaughter

Refactor issuables list to Vue, part 1

```-----------------
**WARNING:** This is one of those long commit messages where multiple
messages are squashed into one
```

-----------------

Working: most of the view, bulk edit toggle.
Not working yet: filtering, bulk edit behaviour.

Enable bulk editing

The bulk edit sidebar is coupled to an issuable's DOM and expects
the correct information to be there in attributes or dataset.

Add pagination to Vue issuable list

Enable filtering in vue issuables list

Enable label drill-down in vue issuables list

Clicking on a label applies a filter for that label.

Implement sorting and reordering in vue issuable list

Fix edge case in issuable ordering

In the Vue issuables list, the list now takes the sort key from
the sort dropdown. The server remembers the previous sort state
even if none is provided in the URL query string, so the
dropdown is the better source of truth.

Fix a feature flag check

Misc improvements to vue issuable list

- Show only opened issues by default
- Apply styles for recent and closed issues

Add dates and assignees in vue issue list

- Replace date placeholders with formatted dates
- Show assignees
- Add options to issue_assignees.vue for more flexibility

Add empty states to vue issue list

Rename message_queue to pubsub

Update gitlab.pot

Fix a spacing issue

Fix bulk update not working

Update some i18n strings

Add user popovers to vue issuables list

Add tooltips in vue issuables list

Apply review suggestions

Update gitlab pot

Add tests for vue issuables list

- Alter Jest unit test for issue assignees component
- Add Jest unit tests for issuables list app and issuable
  components
- Fix rspecs to `wait_for_requests` to allow the Vue app to load
- Misc fixes to utils and Vue components to fix edge cases

Add eventhub to issueables_list

Add constants to issuebales_list

Add datetime_utility to issueables_list

rm pubsub

Add eventbus to issueable_bulk_update

Add coupling comment to _issue.html.haml

add eventbus and moved constants to new file in issuables list app

bind FF to group

Rm FF guard in JS file when initing issueable list

Replace pubsub with eventbus in spec

get rid of rm .js css class from list

Abstract newDateAsLocal time in issueable

Refactor getting sort from backend

Get sort key from backed and avoid querying the dom more than we need to

Refactor tests to use less snapshots

Add issuable_item and move value to computes

Rm conditional when mounting in issuables_list_app

Replace number with constant to access as prop

Ran prettier on touched files

Fix type in consts

Add group to FF controller

Mv newDateAsLocaleTime to datetime_utility

Mv func to lib/datetime_utility and replace instances in files

Add FF guard to index.js for init component

Rm rspec changes that arent needed

Change storing ids in array to object

Fix bulk edit update all and change how we store ids for a more
efficient dom interation

Rm issuable_item component

Rm default values undefined

Replace issuable_item component with computed iteration

Refactor issuable spec and snapshot

Get specs passing from addreessing comments

revert style change

run prettier against files

Update issuable_list_app_spec and snapshots

Add shared examples for FF in issue_spec

Move test from issuable_list to issuable

Reset window date after Spec in issuable

Add group to stubbing FF in issues_spec.rb

Run prettier against files

Final patch

Handle refactoring for issuables and issuables list to address bugs
and spec issues
parent 5d94e9d2
/* eslint-disable class-methods-use-this, no-new */ /* eslint-disable class-methods-use-this, no-new */
import $ from 'jquery'; import $ from 'jquery';
import { property } from 'underscore';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import MilestoneSelect from './milestone_select'; import MilestoneSelect from './milestone_select';
import issueStatusSelect from './issue_status_select'; import issueStatusSelect from './issue_status_select';
import subscriptionSelect from './subscription_select'; import subscriptionSelect from './subscription_select';
import LabelsSelect from './labels_select'; import LabelsSelect from './labels_select';
import issueableEventHub from './issuables_list/eventhub';
const HIDDEN_CLASS = 'hidden'; const HIDDEN_CLASS = 'hidden';
const DISABLED_CONTENT_CLASS = 'disabled-content'; const DISABLED_CONTENT_CLASS = 'disabled-content';
...@@ -14,6 +16,8 @@ const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-si ...@@ -14,6 +16,8 @@ const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-si
export default class IssuableBulkUpdateSidebar { export default class IssuableBulkUpdateSidebar {
constructor() { constructor() {
this.vueIssuablesListFeature = property(['gon', 'features', 'vueIssuablesList'])(window);
this.initDomElements(); this.initDomElements();
this.bindEvents(); this.bindEvents();
this.initDropdowns(); this.initDropdowns();
...@@ -41,6 +45,17 @@ export default class IssuableBulkUpdateSidebar { ...@@ -41,6 +45,17 @@ export default class IssuableBulkUpdateSidebar {
this.$issuesList.on('change', () => this.updateFormState()); this.$issuesList.on('change', () => this.updateFormState());
this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit()); this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
this.$checkAllContainer.on('click', () => this.updateFormState()); 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() { initDropdowns() {
...@@ -73,6 +88,8 @@ export default class IssuableBulkUpdateSidebar { ...@@ -73,6 +88,8 @@ export default class IssuableBulkUpdateSidebar {
toggleBulkEdit(e, enable) { toggleBulkEdit(e, enable) {
e.preventDefault(); e.preventDefault();
issueableEventHub.$emit('issuables:toggleBulkEdit', enable);
this.toggleSidebarDisplay(enable); this.toggleSidebarDisplay(enable);
this.toggleBulkEditButtonDisabled(enable); this.toggleBulkEditButtonDisabled(enable);
this.toggleOtherFiltersDisabled(enable); this.toggleOtherFiltersDisabled(enable);
...@@ -106,7 +123,7 @@ export default class IssuableBulkUpdateSidebar { ...@@ -106,7 +123,7 @@ export default class IssuableBulkUpdateSidebar {
} }
toggleCheckboxDisplay(show) { toggleCheckboxDisplay(show) {
this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show); this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show || this.vueIssuablesListFeature);
this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); 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 => ...@@ -78,11 +78,11 @@ export const getDayName = date =>
* @param {date} datetime * @param {date} datetime
* @returns {String} * @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+ /)) { if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
throw new Error(__('Invalid date')); 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 => { ...@@ -558,6 +558,17 @@ export const calculateRemainingMilliseconds = endDate => {
export const getDateInPast = (date, daysInPast) => export const getDateInPast = (date, daysInPast) =>
new Date(newDate(date).setDate(date.getDate() - 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 beginOfDayTime = 'T00:00:00Z';
export const endOfDayTime = 'T23:59:59Z'; export const endOfDayTime = 'T23:59:59Z';
......
...@@ -18,7 +18,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) => ...@@ -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")); createFlash(s__("ManualOrdering|Couldn't save the order of the issues"));
}); });
const initManualOrdering = () => { const initManualOrdering = (draggableSelector = 'li.issue') => {
const issueList = document.querySelector('.manual-ordering'); const issueList = document.querySelector('.manual-ordering');
if (!issueList || !(gon.current_user_id > 0)) { if (!issueList || !(gon.current_user_id > 0)) {
...@@ -34,14 +34,14 @@ const initManualOrdering = () => { ...@@ -34,14 +34,14 @@ const initManualOrdering = () => {
group: { group: {
name: 'issues', name: 'issues',
}, },
draggable: 'li.issue', draggable: draggableSelector,
onStart: () => { onStart: () => {
sortableStart(); sortableStart();
}, },
onUpdate: event => { onUpdate: event => {
const el = event.item; const el = event.item;
const url = el.getAttribute('url'); const url = el.getAttribute('url') || el.dataset.url;
const prev = el.previousElementSibling; const prev = el.previousElementSibling;
const next = el.nextElementSibling; const next = el.nextElementSibling;
......
import initIssuablesList from '~/issuables_list';
import projectSelect from '~/project_select'; import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search'; import initFilteredSearch from '~/pages/search/init_filtered_search';
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
...@@ -11,6 +12,8 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -11,6 +12,8 @@ document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
initIssuablesList();
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.ISSUES, page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true, isGroupDecendent: true,
......
<script> <script>
import { GlTooltipDirective } from '@gitlab/ui'; import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default { export default {
...@@ -16,44 +15,47 @@ export default { ...@@ -16,44 +15,47 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
iconSize: {
type: Number,
required: false,
default: 24,
},
imgCssClasses: {
type: String,
required: false,
default: '',
},
maxVisible: {
type: Number,
required: false,
default: 3,
},
}, },
data() { data() {
return { return {
maxVisibleAssignees: 2,
maxAssigneeAvatars: 3,
maxAssignees: 99, maxAssignees: 99,
}; };
}, },
computed: { computed: {
countOverLimit() {
return this.assignees.length - this.maxVisibleAssignees;
},
assigneesToShow() { assigneesToShow() {
if (this.assignees.length > this.maxAssigneeAvatars) { const numShownAssignees = this.assignees.length - this.numHiddenAssignees;
return this.assignees.slice(0, this.maxVisibleAssignees); return this.assignees.slice(0, numShownAssignees);
}
return this.assignees;
}, },
assigneesCounterTooltip() { assigneesCounterTooltip() {
const { countOverLimit, maxAssignees } = this; return sprintf(__('%{count} more assignees'), { count: this.numHiddenAssignees });
const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit;
return sprintf(__('%{count} more assignees'), { count });
}, },
shouldRenderAssigneesCounter() { numHiddenAssignees() {
const assigneesCount = this.assignees.length; if (this.assignees.length > this.maxVisible) {
if (assigneesCount <= this.maxAssigneeAvatars) { return this.assignees.length - this.maxVisible + 1;
return false;
} }
return 0;
return assigneesCount > this.countOverLimit;
}, },
assigneeCounterLabel() { assigneeCounterLabel() {
if (this.countOverLimit > this.maxAssignees) { if (this.numHiddenAssignees > this.maxAssignees) {
return `${this.maxAssignees}+`; return `${this.maxAssignees}+`;
} }
return `+${this.countOverLimit}`; return `+${this.numHiddenAssignees}`;
}, },
}, },
methods: { methods: {
...@@ -81,8 +83,9 @@ export default { ...@@ -81,8 +83,9 @@ export default {
:key="assignee.id" :key="assignee.id"
:link-href="webUrl(assignee)" :link-href="webUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)" :img-alt="avatarUrlTitle(assignee)"
:img-css-classes="imgCssClasses"
:img-src="avatarUrl(assignee)" :img-src="avatarUrl(assignee)"
:img-size="24" :img-size="iconSize"
class="js-no-trigger" class="js-no-trigger"
tooltip-placement="bottom" tooltip-placement="bottom"
> >
...@@ -92,7 +95,7 @@ export default { ...@@ -92,7 +95,7 @@ export default {
</span> </span>
</user-avatar-link> </user-avatar-link>
<span <span
v-if="shouldRenderAssigneesCounter" v-if="numHiddenAssignees > 0"
v-gl-tooltip v-gl-tooltip
:title="assigneesCounterTooltip" :title="assigneesCounterTooltip"
class="avatar-counter" class="avatar-counter"
......
...@@ -25,6 +25,10 @@ class GroupsController < Groups::ApplicationController ...@@ -25,6 +25,10 @@ class GroupsController < Groups::ApplicationController
before_action :user_actions, only: [:show] 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, skip_cross_project_access_check :index, :new, :create, :edit, :update,
:destroy, :projects :destroy, :projects
# When loading show as an atom feed, we render events that could leak cross # When loading show as an atom feed, we render events that could leak cross
......
...@@ -22,4 +22,10 @@ ...@@ -22,4 +22,10 @@
- if @can_bulk_update - if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues = 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 } } %li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } }
.issue-box .issue-box
- if @can_bulk_update - if @can_bulk_update
......
...@@ -324,6 +324,9 @@ msgstr "" ...@@ -324,6 +324,9 @@ msgstr ""
msgid "%{percent}%% complete" msgid "%{percent}%% complete"
msgstr "" msgstr ""
msgid "%{primary} (%{secondary})"
msgstr ""
msgid "%{releases} release" msgid "%{releases} release"
msgid_plural "%{releases} releases" msgid_plural "%{releases} releases"
msgstr[0] "" msgstr[0] ""
...@@ -1627,6 +1630,9 @@ msgstr "" ...@@ -1627,6 +1630,9 @@ msgstr ""
msgid "An error occurred while loading filenames" msgid "An error occurred while loading filenames"
msgstr "" msgstr ""
msgid "An error occurred while loading issues"
msgstr ""
msgid "An error occurred while loading the file" msgid "An error occurred while loading the file"
msgstr "" msgstr ""
...@@ -20650,6 +20656,9 @@ msgstr "" ...@@ -20650,6 +20656,9 @@ msgstr ""
msgid "nounSeries|%{item}, and %{lastItem}" msgid "nounSeries|%{item}, and %{lastItem}"
msgstr "" msgstr ""
msgid "opened %{timeAgoString} by %{user}"
msgstr ""
msgid "or %{link_start}create a new Google account%{link_end}" msgid "or %{link_start}create a new Google account%{link_end}"
msgstr "" msgstr ""
......
...@@ -26,6 +26,10 @@ describe 'Explore Groups', :js do ...@@ -26,6 +26,10 @@ describe 'Explore Groups', :js do
end end
end end
before do
stub_feature_flags({ vue_issuables_list: { enabled: false, thing: group } })
end
shared_examples 'renders public and internal projects' do shared_examples 'renders public and internal projects' do
it do it do
visit_page visit_page
......
...@@ -11,6 +11,10 @@ describe 'Group issues page' do ...@@ -11,6 +11,10 @@ describe 'Group issues page' do
let(:project_with_issues_disabled) { create(:project, :issues_disabled, group: group) } let(:project_with_issues_disabled) { create(:project, :issues_disabled, group: group) }
let(:path) { issues_group_path(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 context 'with shared examples' do
let(:issuable) { create(:issue, project: project, title: "this is my created issuable")} 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 IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { mockAssigneesList } from '../../../../javascripts/boards/mock_data'; import { mockAssigneesList } from '../../../../javascripts/boards/mock_data';
const createComponent = (assignees = mockAssigneesList, cssClass = '') => { const TEST_CSS_CLASSES = 'test-classes';
const Component = Vue.extend(IssueAssignees); const TEST_MAX_VISIBLE = 4;
const TEST_ICON_SIZE = 16;
return mountComponent(Component, {
assignees,
cssClass,
});
};
describe('IssueAssigneesComponent', () => { describe('IssueAssigneesComponent', () => {
let wrapper;
let vm; let vm;
beforeEach(() => { const factory = props => {
vm = createComponent(); wrapper = shallowMount(IssueAssignees, {
}); propsData: {
assignees: mockAssigneesList,
afterEach(() => { ...props,
vm.$destroy(); },
}); sync: false,
describe('data', () => {
it('returns default data props', () => {
expect(vm.maxVisibleAssignees).toBe(2);
expect(vm.maxAssigneeAvatars).toBe(3);
expect(vm.maxAssignees).toBe(99);
}); });
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.each`
describe('countOverLimit', () => { numAssignees | maxVisible | expectedShown | expectedHidden
it('should return difference between assignees count and maxVisibleAssignees', () => { ${0} | ${3} | ${0} | ${''}
expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees); ${1} | ${3} | ${1} | ${''}
}); ${2} | ${3} | ${2} | ${''}
}); ${3} | ${3} | ${3} | ${''}
${4} | ${3} | ${2} | ${'+2'}
describe('assigneesToShow', () => { ${5} | ${2} | ${1} | ${'+4'}
it('should return assignees containing only 2 items when count more than maxAssigneeAvatars', () => { ${1000} | ${5} | ${4} | ${'99+'}
expect(vm.assigneesToShow.length).toBe(2); `(
}); 'with assignees ($numAssignees) and maxVisible ($maxVisible)',
({ numAssignees, maxVisible, expectedShown, expectedHidden }) => {
it('should return all assignees as it is when count less than maxAssigneeAvatars', () => { beforeEach(() => {
vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees factory({ assignees: Array(numAssignees).fill({}), maxVisible });
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('assigneeCounterLabel', () => { if (expectedShown) {
it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => { it('shows assignee avatars', () => {
expect(vm.assigneeCounterLabel).toBe('+3'); 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', () => { it('computes alt text for assignee avatar', () => {
describe('avatarUrlTitle', () => { expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
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`', () => { 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', () => { it('renders assignee', () => {
expect(vm.$el.querySelectorAll('.user-avatar-link').length).toBe(2); 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', () => { describe('assignee tooltips', () => {
const tooltipText = vm.$el it('renders "Assignee" header', () => {
.querySelectorAll('.user-avatar-link')[0] expect(findTooltipText()).toContain('Assignee');
.querySelector('.js-assignee-tooltip').innerText; });
expect(tooltipText).toContain('Assignee');
expect(tooltipText).toContain('Terrell Graham');
expect(tooltipText).toContain('@monserrate.gleichner');
});
it('renders additional assignees count', () => { it('renders assignee name', () => {
const avatarCounterEl = vm.$el.querySelector('.avatar-counter'); expect(findTooltipText()).toContain('Terrell Graham');
});
expect(avatarCounterEl.innerText.trim()).toBe('+3'); it('renders assignee @username', () => {
expect(avatarCounterEl.getAttribute('data-original-title')).toBe('3 more assignees'); 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