Commit 0af2acc8 authored by Coung Ngo's avatar Coung Ngo Committed by Kushal Pandya

Start refactoring issues list page with `issuable_list` component

Replace issues list page Haml search bar and list with Vue
`issuable_list` component under the feature flag `vue_issues_list`.

This change:

- Adds missing UI elements to `issuable_item.vue` for issues
- Adds pagination

Subsequent MRs will:

- Add sorting
- Add searching
- Add filtering
- Add missing filtered-search tokens
- Add empty state
- Add bulk edit
parent cffdff1f
...@@ -5,7 +5,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; ...@@ -5,7 +5,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils'; import { isScopedLabel } from '~/lib/utils/common_utils';
import { getTimeago } from '~/lib/utils/datetime_utility'; import { getTimeago } from '~/lib/utils/datetime_utility';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale'; import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
...@@ -86,8 +86,26 @@ export default { ...@@ -86,8 +86,26 @@ export default {
} }
return {}; return {};
}, },
taskStatus() {
const { completedCount, count } = this.issuable.taskCompletionStatus || {};
if (!count) {
return undefined;
}
return sprintf(
n__(
'%{completedCount} of %{count} task completed',
'%{completedCount} of %{count} tasks completed',
count,
),
{ completedCount, count },
);
},
notesCount() {
return this.issuable.userDiscussionsCount ?? this.issuable.userNotesCount;
},
showDiscussions() { showDiscussions() {
return typeof this.issuable.userDiscussionsCount === 'number'; return typeof this.notesCount === 'number';
}, },
showIssuableMeta() { showIssuableMeta() {
return Boolean( return Boolean(
...@@ -148,19 +166,27 @@ export default { ...@@ -148,19 +166,27 @@ export default {
v-gl-tooltip v-gl-tooltip
name="eye-slash" name="eye-slash"
:title="__('Confidential')" :title="__('Confidential')"
:aria-label="__('Confidential')"
/> />
<gl-link :href="webUrl" v-bind="issuableTitleProps" <gl-link :href="webUrl" v-bind="issuableTitleProps"
>{{ issuable.title >{{ issuable.title
}}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
/></gl-link> /></gl-link>
</span> </span>
<span
v-if="taskStatus"
class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
data-testid="task-status"
>
{{ taskStatus }}
</span>
</div> </div>
<div class="issuable-info"> <div class="issuable-info">
<slot v-if="hasSlotContents('reference')" name="reference"></slot> <slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference" <span v-else data-testid="issuable-reference" class="issuable-reference"
>{{ issuableSymbol }}{{ issuable.iid }}</span >{{ issuableSymbol }}{{ issuable.iid }}</span
> >
<span class="issuable-authored d-none d-sm-inline-block"> <span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3">
&middot; &middot;
<span <span
v-gl-tooltip:tooltipcontainer.bottom v-gl-tooltip:tooltipcontainer.bottom
...@@ -203,6 +229,16 @@ export default { ...@@ -203,6 +229,16 @@ export default {
<li v-if="hasSlotContents('status')" class="issuable-status"> <li v-if="hasSlotContents('status')" class="issuable-status">
<slot name="status"></slot> <slot name="status"></slot>
</li> </li>
<li v-if="assignees.length" class="gl-display-flex">
<issuable-assignees
:assignees="assignees"
:icon-size="16"
:max-visible="4"
img-css-classes="gl-mr-2!"
class="gl-align-items-center gl-display-flex gl-ml-3"
/>
</li>
<slot name="statistics"></slot>
<li <li
v-if="showDiscussions" v-if="showDiscussions"
data-testid="issuable-discussions" data-testid="issuable-discussions"
...@@ -212,26 +248,17 @@ export default { ...@@ -212,26 +248,17 @@ export default {
v-gl-tooltip:tooltipcontainer.top v-gl-tooltip:tooltipcontainer.top
:title="__('Comments')" :title="__('Comments')"
:href="issuableNotesLink" :href="issuableNotesLink"
:class="{ 'no-comments': !issuable.userDiscussionsCount }" :class="{ 'no-comments': !notesCount }"
class="gl-reset-color!" class="gl-reset-color!"
> >
<gl-icon name="comments" /> <gl-icon name="comments" />
{{ issuable.userDiscussionsCount }} {{ notesCount }}
</gl-link> </gl-link>
</li> </li>
<li v-if="assignees.length" class="gl-display-flex">
<issuable-assignees
:assignees="issuable.assignees"
:icon-size="16"
:max-visible="4"
img-css-classes="gl-mr-2!"
class="gl-align-items-center gl-display-flex gl-ml-3"
/>
</li>
</ul> </ul>
<div <div
data-testid="issuable-updated-at" data-testid="issuable-updated-at"
class="float-right issuable-updated-at d-none d-sm-inline-block" class="float-right issuable-updated-at gl-display-none gl-sm-display-inline-block"
> >
<span <span
v-gl-tooltip:tooltipcontainer.bottom v-gl-tooltip:tooltipcontainer.bottom
......
...@@ -280,6 +280,9 @@ export default { ...@@ -280,6 +280,9 @@ export default {
<template #status> <template #status>
<slot name="status" :issuable="issuable"></slot> <slot name="status" :issuable="issuable"></slot>
</template> </template>
<template #statistics>
<slot name="statistics" :issuable="issuable"></slot>
</template>
</issuable-item> </issuable-item>
</ul> </ul>
<slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot> <slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>
......
<script>
import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import {
dateInWords,
getTimeRemainingInWords,
isInFuture,
isInPast,
isToday,
} from '~/lib/utils/datetime_utility';
import { convertToCamelCase } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
export default {
components: {
GlLink,
GlIcon,
IssueHealthStatus: () =>
import('ee_component/related_items_tree/components/issue_health_status.vue'),
WeightCount: () => import('ee_component/issues/components/weight_count.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
hasIssuableHealthStatusFeature: {
default: false,
},
},
props: {
issue: {
type: Object,
required: true,
},
},
computed: {
milestoneDate() {
if (this.issue.milestone?.dueDate) {
const { dueDate, startDate } = this.issue.milestone;
const date = dateInWords(new Date(dueDate), true);
const remainingTime = this.milestoneRemainingTime(dueDate, startDate);
return `${date} (${remainingTime})`;
}
return __('Milestone');
},
dueDate() {
return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true);
},
isDueDateInPast() {
return isInPast(new Date(this.issue.dueDate));
},
timeEstimate() {
return this.issue.timeStats?.humanTimeEstimate;
},
showHealthStatus() {
return this.hasIssuableHealthStatusFeature && this.issue.healthStatus;
},
healthStatus() {
return convertToCamelCase(this.issue.healthStatus);
},
},
methods: {
milestoneRemainingTime(dueDate, startDate) {
const due = new Date(dueDate);
const start = new Date(startDate);
if (dueDate && isInPast(due)) {
return __('Past due');
} else if (dueDate && isToday(due)) {
return __('Today');
} else if (startDate && isInFuture(start)) {
return __('Upcoming');
} else if (dueDate) {
return getTimeRemainingInWords(due);
}
return '';
},
},
};
</script>
<template>
<span>
<span
v-if="issue.milestone"
class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3"
data-testid="issuable-milestone"
>
<gl-link v-gl-tooltip :href="issue.milestone.webUrl" :title="milestoneDate">
<gl-icon name="clock" />
{{ issue.milestone.title }}
</gl-link>
</span>
<span
v-if="issue.dueDate"
v-gl-tooltip
class="issuable-due-date gl-display-none gl-sm-display-inline-block! gl-mr-3"
:class="{ 'gl-text-red-500': isDueDateInPast }"
:title="__('Due date')"
data-testid="issuable-due-date"
>
<gl-icon name="calendar" />
{{ dueDate }}
</span>
<span
v-if="timeEstimate"
v-gl-tooltip
class="gl-display-none gl-sm-display-inline-block! gl-mr-3"
:title="__('Estimate')"
data-testid="time-estimate"
>
<gl-icon name="timer" />
{{ timeEstimate }}
</span>
<weight-count
class="gl-display-none gl-sm-display-inline-block gl-mr-3"
:weight="issue.weight"
/>
<issue-health-status
v-if="showHealthStatus"
class="gl-display-none gl-sm-display-inline-block"
:health-status="healthStatus"
/>
</span>
</template>
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { toNumber } from 'lodash';
import createFlash from '~/flash';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableStatus } from '~/issue_show/constants';
import { PAGE_SIZE } from '~/issues_list/constants';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import IssueCardTimeInfo from './issue_card_time_info.vue';
export default {
PAGE_SIZE,
components: {
GlIcon,
IssuableList,
IssueCardTimeInfo,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
endpoint: {
default: '',
},
fullPath: {
default: '',
},
},
data() {
return {
currentPage: toNumber(getParameterByName('page')) || 1,
isLoading: false,
issues: [],
totalIssues: 0,
};
},
computed: {
urlParams() {
return {
page: this.currentPage,
state: IssuableStatus.Open,
};
},
},
mounted() {
this.fetchIssues();
},
methods: {
fetchIssues(pageToFetch) {
this.isLoading = true;
return axios
.get(this.endpoint, {
params: {
page: pageToFetch || this.currentPage,
per_page: this.$options.PAGE_SIZE,
state: IssuableStatus.Open,
with_labels_details: true,
},
})
.then(({ data, headers }) => {
this.currentPage = Number(headers['x-page']);
this.totalIssues = Number(headers['x-total']);
this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true }));
})
.catch(() => {
createFlash({ message: __('An error occurred while loading issues') });
})
.finally(() => {
this.isLoading = false;
});
},
handlePageChange(page) {
this.fetchIssues(page);
},
},
};
</script>
<template>
<issuable-list
:namespace="fullPath"
recent-searches-storage-key="issues"
:search-input-placeholder="__('Search or filter results…')"
:search-tokens="[]"
:sort-options="[]"
:issuables="issues"
:tabs="[]"
current-tab=""
:issuables-loading="isLoading"
:show-pagination-controls="true"
:total-items="totalIssues"
:current-page="currentPage"
:previous-page="currentPage - 1"
:next-page="currentPage + 1"
:url-params="urlParams"
@page-change="handlePageChange"
>
<template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" />
</template>
<template #statistics="{ issuable = {} }">
<li
v-if="issuable.mergeRequestsCount"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Related merge requests')"
data-testid="issuable-mr"
>
<gl-icon name="merge-request" />
{{ issuable.mergeRequestsCount }}
</li>
<li
v-if="issuable.upvotes"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Upvotes')"
data-testid="issuable-upvotes"
>
<gl-icon name="thumb-up" />
{{ issuable.upvotes }}
</li>
<li
v-if="issuable.downvotes"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Downvotes')"
data-testid="issuable-downvotes"
>
<gl-icon name="thumb-down" />
{{ issuable.downvotes }}
</li>
<blocking-issues-count
class="gl-display-none gl-sm-display-block"
:blocking-issues-count="issuable.blockingIssuesCount"
:is-list-item="true"
/>
</template>
</issuable-list>
</template>
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import IssuablesListApp from './components/issuables_list_app.vue'; import IssuablesListApp from './components/issuables_list_app.vue';
import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue'; import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue';
...@@ -64,6 +65,37 @@ function mountIssuablesListApp() { ...@@ -64,6 +65,37 @@ function mountIssuablesListApp() {
}); });
} }
export function initIssuesListApp() {
const el = document.querySelector('.js-issues-list');
if (!el) {
return false;
}
const {
endpoint,
fullPath,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
} = el.dataset;
return new Vue({
el,
// Currently does not use Vue Apollo, but need to provide {} for now until the
// issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
apolloProvider: {},
provide: {
endpoint,
fullPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
},
render: (createComponent) => createComponent(IssuesListApp),
});
}
export default function initIssuablesList() { export default function initIssuablesList() {
mountJiraIssuesListApp(); mountJiraIssuesListApp();
mountIssuablesListApp(); mountIssuablesListApp();
......
...@@ -768,6 +768,19 @@ export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => { ...@@ -768,6 +768,19 @@ export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => {
return new Date(cloneValue); return new Date(cloneValue);
}; };
/**
* Returns the date `n` years after the date provided.
*
* @param {Date} date the initial date
* @param {Number} numberOfYears number of years after
* @return {Date} A `Date` object `n` years after the provided `Date`
*/
export const nYearsAfter = (date, numberOfYears) => {
const clone = newDate(date);
clone.setFullYear(clone.getFullYear() + numberOfYears);
return clone;
};
/** /**
* Returns the date `n` months before the date provided * Returns the date `n` months before the date provided
* *
...@@ -992,6 +1005,78 @@ export const isToday = (date) => { ...@@ -992,6 +1005,78 @@ export const isToday = (date) => {
); );
}; };
/**
* Checks whether the date is in the past.
*
* @param {Date} date
* @return {Boolean} Returns true if the date falls before today, otherwise false.
*/
export const isInPast = (date) => !isToday(date) && differenceInMilliseconds(date, Date.now()) > 0;
/**
* Checks whether the date is in the future.
* .
* @param {Date} date
* @return {Boolean} Returns true if the date falls after today, otherwise false.
*/
export const isInFuture = (date) =>
!isToday(date) && differenceInMilliseconds(Date.now(), date) > 0;
/**
* Checks whether dateA falls before dateB.
*
* @param {Date} dateA
* @param {Date} dateB
* @return {Boolean} Returns true if dateA falls before dateB, otherwise false
*/
export const fallsBefore = (dateA, dateB) => differenceInMilliseconds(dateA, dateB) > 0;
/**
* Removes the time component of the date.
*
* @param {Date} date
* @return {Date} Returns a clone of the date with the time set to midnight
*/
export const removeTime = (date) => {
const clone = newDate(date);
clone.setHours(0, 0, 0, 0);
return clone;
};
/**
* Calculates the time remaining from today in words in the format
* `n days/weeks/months/years remaining`.
*
* @param {Date} date A date in future
* @return {String} The time remaining in the format `n days/weeks/months/years remaining`
*/
export const getTimeRemainingInWords = (date) => {
const today = removeTime(new Date());
const dateInFuture = removeTime(date);
const oneWeekFromNow = nWeeksAfter(today, 1);
const oneMonthFromNow = nMonthsAfter(today, 1);
const oneYearFromNow = nYearsAfter(today, 1);
if (fallsBefore(dateInFuture, oneWeekFromNow)) {
const days = getDayDifference(today, dateInFuture);
return n__('1 day remaining', '%d days remaining', days);
}
if (fallsBefore(dateInFuture, oneMonthFromNow)) {
const weeks = Math.floor(getDayDifference(today, dateInFuture) / 7);
return n__('1 week remaining', '%d weeks remaining', weeks);
}
if (fallsBefore(dateInFuture, oneYearFromNow)) {
const months = differenceInMonths(today, dateInFuture);
return n__('1 month remaining', '%d months remaining', months);
}
const years = dateInFuture.getFullYear() - today.getFullYear();
return n__('1 year remaining', '%d years remaining', years);
};
/** /**
* Returns the start of the provided day * Returns the start of the provided day
* *
......
...@@ -5,7 +5,7 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; ...@@ -5,7 +5,7 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons'; import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email'; import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index'; import IssuableIndex from '~/issuable_index';
import initIssuablesList from '~/issues_list'; import initIssuablesList, { initIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/manual_ordering'; import initManualOrdering from '~/manual_ordering';
import { FILTERED_SEARCH } from '~/pages/constants'; import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants';
...@@ -28,3 +28,4 @@ initManualOrdering(); ...@@ -28,3 +28,4 @@ initManualOrdering();
initIssuablesList(); initIssuablesList();
initIssuableByEmail(); initIssuableByEmail();
initCsvImportExportButtons(); initCsvImportExportButtons();
initIssuesListApp();
...@@ -17,16 +17,25 @@ ...@@ -17,16 +17,25 @@
.top-area .top-area
= render 'shared/issuable/nav', type: :issues = render 'shared/issuable/nav', type: :issues
= render "projects/issues/nav_btns" = render "projects/issues/nav_btns"
= render 'shared/issuable/search_bar', type: :issues
- if @can_bulk_update - if Feature.enabled?(:vue_issues_list, @project)
= render 'shared/issuable/bulk_update_sidebar', type: :issues - data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
.js-issues-list{ data: { endpoint: data_endpoint,
full_path: @project.full_path,
has_blocked_issues_feature: Gitlab.ee? && @project.feature_available?(:blocked_issues).to_s,
has_issuable_health_status_feature: Gitlab.ee? && @project.feature_available?(:issuable_health_status).to_s,
has_issue_weights_feature: Gitlab.ee? && @project.feature_available?(:issue_weights).to_s } }
- else
= render 'shared/issuable/search_bar', type: :issues
.issues-holder - if @can_bulk_update
= render 'issues' = render 'shared/issuable/bulk_update_sidebar', type: :issues
- if new_issue_email
.issuable-footer.text-center .issues-holder
.js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } } = render 'issues'
- if new_issue_email
.issuable-footer.text-center
.js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
- else - else
- new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project) - new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project)
= render 'shared/empty_states/issues', new_project_issue_button_path: new_project_issue_button_path, show_import_button: true = render 'shared/empty_states/issues', new_project_issue_button_path: new_project_issue_button_path, show_import_button: true
---
name: vue_issues_list
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55699
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323743
milestone: '13.10'
type: development
group: group::project management
default_enabled: false
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
hasBlockedIssuesFeature: {
default: false,
},
},
props: {
blockingIssuesCount: {
type: Number,
required: false,
default: null,
},
isListItem: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
showBlockingIssuesCount() {
return this.hasBlockedIssuesFeature && this.blockingIssuesCount > 0;
},
tag() {
return this.isListItem ? 'li' : 'span';
},
},
};
</script>
<template>
<span :is="tag" v-if="showBlockingIssuesCount" v-gl-tooltip :title="__('Blocking issues')">
<gl-icon name="issue-block" />
{{ blockingIssuesCount }}
</span>
</template>
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
hasIssueWeightsFeature: {
default: false,
},
},
props: {
weight: {
type: Number,
required: false,
default: null,
},
},
computed: {
showWeight() {
return this.hasIssueWeightsFeature && this.weight != null;
},
},
};
</script>
<template>
<span v-if="showWeight" v-gl-tooltip :title="__('Weight')">
<gl-icon name="weight" />
{{ weight }}
</span>
</template>
...@@ -22,8 +22,8 @@ export default { ...@@ -22,8 +22,8 @@ export default {
</script> </script>
<template> <template>
<div class="health-status d-inline-flex align-items-center"> <div class="health-status">
<span class="gl-label gl-label-text-dark gl-label-sm" :class="cssMapping"> <span class="gl-label gl-label-sm" :class="cssMapping">
<span class="gl-label-text"> <span class="gl-label-text">
{{ getFormattedStatus }} {{ getFormattedStatus }}
</span> </span>
......
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import BlockingIssuesCount from 'ee/issues/components/blocking_issues_count.vue';
describe('BlockingIssuesCount component', () => {
const iconName = 'issue-block';
const tooltipText = 'Blocking issues';
let wrapper;
const findIcon = () => wrapper.findComponent(GlIcon);
const mountComponent = ({
blockingIssuesCount = 1,
hasBlockedIssuesFeature = true,
isListItem = false,
} = {}) =>
shallowMount(BlockingIssuesCount, {
propsData: { blockingIssuesCount, isListItem },
provide: { hasBlockedIssuesFeature },
});
afterEach(() => {
wrapper.destroy();
});
describe('with blocked_issues license', () => {
describe('when blocking issues count is positive', () => {
beforeEach(() => {
wrapper = mountComponent({ blockingIssuesCount: 1 });
});
it('renders blocking issues count', () => {
expect(wrapper.text()).toBe('1');
expect(wrapper.attributes('title')).toBe(tooltipText);
expect(findIcon().props('name')).toBe(iconName);
});
});
describe.each([0, null])('when blocking issues count is %s', (i) => {
beforeEach(() => {
wrapper = mountComponent({ blockingIssuesCount: i });
});
it('does not render blocking issues', () => {
expect(wrapper.text()).toBe('');
});
});
describe('when element is a list item', () => {
beforeEach(() => {
wrapper = mountComponent({ isListItem: true });
});
it('renders as `li` element', () => {
expect(wrapper.element.tagName).toBe('LI');
});
});
});
describe('without blocked_issues license', () => {
beforeEach(() => {
wrapper = mountComponent({ hasBlockedIssuesFeature: false });
});
it('does not render blocking issues', () => {
expect(wrapper.text()).toBe('');
});
});
});
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import WeightCount from 'ee/issues/components/weight_count.vue';
describe('WeightCount component', () => {
const iconName = 'weight';
const tooltipText = 'Weight';
let wrapper;
const findIcon = () => wrapper.findComponent(GlIcon);
const mountComponent = ({ weight = 1, hasIssueWeightsFeature = true } = {}) =>
shallowMount(WeightCount, {
propsData: { weight },
provide: { hasIssueWeightsFeature },
});
afterEach(() => {
wrapper.destroy();
});
describe('with issue_weights license', () => {
describe.each([1, 0])('when weight is %d', (i) => {
beforeEach(() => {
wrapper = mountComponent({ weight: i });
});
it('renders weight', () => {
expect(wrapper.text()).toBe(i.toString());
expect(wrapper.attributes('title')).toBe(tooltipText);
expect(findIcon().props('name')).toBe(iconName);
});
});
describe('when weight is null', () => {
beforeEach(() => {
wrapper = mountComponent({ weight: null });
});
it('does not render weight', () => {
expect(wrapper.text()).toBe('');
});
});
});
describe('without issue_weights license', () => {
beforeEach(() => {
wrapper = mountComponent({ hasIssueWeightsFeature: false });
});
it('does not render weight', () => {
expect(wrapper.text()).toBe('');
});
});
});
...@@ -409,6 +409,11 @@ msgstr "" ...@@ -409,6 +409,11 @@ msgstr ""
msgid "%{completedCount} completed weight" msgid "%{completedCount} completed weight"
msgstr "" msgstr ""
msgid "%{completedCount} of %{count} task completed"
msgid_plural "%{completedCount} of %{count} tasks completed"
msgstr[0] ""
msgstr[1] ""
msgid "%{completedWeight} of %{totalWeight} weight completed" msgid "%{completedWeight} of %{totalWeight} weight completed"
msgstr "" msgstr ""
...@@ -1151,6 +1156,11 @@ msgid_plural "%d days" ...@@ -1151,6 +1156,11 @@ msgid_plural "%d days"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "1 day remaining"
msgid_plural "%d days remaining"
msgstr[0] ""
msgstr[1] ""
msgid "1 deploy key" msgid "1 deploy key"
msgid_plural "%d deploy keys" msgid_plural "%d deploy keys"
msgstr[0] "" msgstr[0] ""
...@@ -1191,6 +1201,11 @@ msgid_plural "%d minutes" ...@@ -1191,6 +1201,11 @@ msgid_plural "%d minutes"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "1 month remaining"
msgid_plural "%d months remaining"
msgstr[0] ""
msgstr[1] ""
msgid "1 open issue" msgid "1 open issue"
msgid_plural "%{issues} open issues" msgid_plural "%{issues} open issues"
msgstr[0] "" msgstr[0] ""
...@@ -1216,6 +1231,16 @@ msgid_plural "%{num} users" ...@@ -1216,6 +1231,16 @@ msgid_plural "%{num} users"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "1 week remaining"
msgid_plural "%d weeks remaining"
msgstr[0] ""
msgstr[1] ""
msgid "1 year remaining"
msgid_plural "%d years remaining"
msgstr[0] ""
msgstr[1] ""
msgid "1-9 contributions" msgid "1-9 contributions"
msgstr "" msgstr ""
......
...@@ -294,7 +294,17 @@ describe('IssuableItem', () => { ...@@ -294,7 +294,17 @@ describe('IssuableItem', () => {
expect(confidentialEl.exists()).toBe(true); expect(confidentialEl.exists()).toBe(true);
expect(confidentialEl.props('name')).toBe('eye-slash'); expect(confidentialEl.props('name')).toBe('eye-slash');
expect(confidentialEl.attributes('title')).toBe('Confidential'); expect(confidentialEl.attributes()).toMatchObject({
title: 'Confidential',
arialabel: 'Confidential',
});
});
it('renders task status', () => {
const taskStatus = wrapper.find('[data-testid="task-status"]');
const expected = `${mockIssuable.taskCompletionStatus.completedCount} of ${mockIssuable.taskCompletionStatus.count} tasks completed`;
expect(taskStatus.text()).toBe(expected);
}); });
it('renders issuable reference', () => { it('renders issuable reference', () => {
......
...@@ -53,6 +53,10 @@ export const mockIssuable = { ...@@ -53,6 +53,10 @@ export const mockIssuable = {
}, },
assignees: [mockAuthor], assignees: [mockAuthor],
userDiscussionsCount: 2, userDiscussionsCount: 2,
taskCompletionStatus: {
count: 2,
completedCount: 1,
},
}; };
export const mockIssuables = [ export const mockIssuables = [
......
import { GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { useFakeDate } from 'helpers/fake_date';
import IssueCardTimeInfo from '~/issues_list/components/issue_card_time_info.vue';
describe('IssuesListApp component', () => {
useFakeDate(2020, 11, 11);
let wrapper;
const issue = {
milestone: {
dueDate: '2020-12-17',
startDate: '2020-12-10',
title: 'My milestone',
webUrl: '/milestone/webUrl',
},
dueDate: '2020-12-12',
timeStats: {
humanTimeEstimate: '1w',
},
};
const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]');
const findMilestoneTitle = () => findMilestone().find(GlLink).attributes('title');
const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]');
const mountComponent = ({
dueDate = issue.dueDate,
milestoneDueDate = issue.milestone.dueDate,
milestoneStartDate = issue.milestone.startDate,
} = {}) =>
shallowMount(IssueCardTimeInfo, {
propsData: {
issue: {
...issue,
milestone: {
...issue.milestone,
dueDate: milestoneDueDate,
startDate: milestoneStartDate,
},
dueDate,
},
},
});
afterEach(() => {
wrapper.destroy();
});
describe('milestone', () => {
it('renders', () => {
wrapper = mountComponent();
const milestone = findMilestone();
expect(milestone.text()).toBe(issue.milestone.title);
expect(milestone.find(GlIcon).props('name')).toBe('clock');
expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webUrl);
});
describe.each`
time | text | milestoneDueDate | milestoneStartDate | expected
${'due date is in past'} | ${'Past due'} | ${'2020-09-09'} | ${null} | ${'Sep 9, 2020 (Past due)'}
${'due date is today'} | ${'Today'} | ${'2020-12-11'} | ${null} | ${'Dec 11, 2020 (Today)'}
${'start date is in future'} | ${'Upcoming'} | ${'2021-03-01'} | ${'2021-02-01'} | ${'Mar 1, 2021 (Upcoming)'}
${'due date is in future'} | ${'2 weeks remaining'} | ${'2020-12-25'} | ${null} | ${'Dec 25, 2020 (2 weeks remaining)'}
`('when $description', ({ text, milestoneDueDate, milestoneStartDate, expected }) => {
it(`renders with "${text}"`, () => {
wrapper = mountComponent({ milestoneDueDate, milestoneStartDate });
expect(findMilestoneTitle()).toBe(expected);
});
});
});
describe('due date', () => {
describe('when upcoming', () => {
it('renders', () => {
wrapper = mountComponent();
const dueDate = findDueDate();
expect(dueDate.text()).toBe('Dec 12, 2020');
expect(dueDate.attributes('title')).toBe('Due date');
expect(dueDate.find(GlIcon).props('name')).toBe('calendar');
expect(dueDate.classes()).not.toContain('gl-text-red-500');
});
});
describe('when in the past', () => {
it('renders in red', () => {
wrapper = mountComponent({ dueDate: new Date('2020-10-10') });
expect(findDueDate().classes()).toContain('gl-text-red-500');
});
});
});
it('renders time estimate', () => {
wrapper = mountComponent();
const timeEstimate = wrapper.find('[data-testid="time-estimate"]');
expect(timeEstimate.text()).toBe(issue.timeStats.humanTimeEstimate);
expect(timeEstimate.attributes('title')).toBe('Estimate');
expect(timeEstimate.find(GlIcon).props('name')).toBe('timer');
});
});
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import axios from '~/lib/utils/axios_utils';
describe('IssuesListApp component', () => {
let axiosMock;
let wrapper;
const fullPath = 'path/to/project';
const endpoint = 'api/endpoint';
const state = 'opened';
const xPage = 1;
const xTotal = 25;
const fetchIssuesResponse = {
data: [],
headers: {
'x-page': xPage,
'x-total': xTotal,
},
};
const findIssuableList = () => wrapper.findComponent(IssuableList);
const mountComponent = () =>
shallowMount(IssuesListApp, {
provide: {
endpoint,
fullPath,
},
});
beforeEach(async () => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
wrapper = mountComponent();
await waitForPromises();
});
afterEach(() => {
axiosMock.reset();
wrapper.destroy();
});
it('renders IssuableList', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: fullPath,
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: 'Search or filter results…',
showPaginationControls: true,
issuables: [],
totalItems: xTotal,
currentPage: xPage,
previousPage: xPage - 1,
nextPage: xPage + 1,
urlParams: { page: xPage, state },
});
});
describe('when "page-change" event is emitted', () => {
const data = [{ id: 10, title: 'title', state }];
const page = 2;
const totalItems = 21;
beforeEach(async () => {
axiosMock.onGet(endpoint).reply(200, data, {
'x-page': page,
'x-total': totalItems,
});
findIssuableList().vm.$emit('page-change', page);
await waitForPromises();
});
it('fetches issues with expected params', async () => {
expect(axiosMock.history.get[1].params).toEqual({
page,
per_page: 20,
state,
with_labels_details: true,
});
});
it('updates IssuableList with response data', () => {
expect(findIssuableList().props()).toMatchObject({
issuables: data,
totalItems,
currentPage: page,
previousPage: page - 1,
nextPage: page + 1,
urlParams: { page, state },
});
});
});
});
...@@ -764,6 +764,21 @@ describe('date addition/subtraction methods', () => { ...@@ -764,6 +764,21 @@ describe('date addition/subtraction methods', () => {
); );
}); });
describe('nYearsAfter', () => {
it.each`
date | numberOfYears | expected
${'2020-07-06'} | ${1} | ${'2021-07-06'}
${'2020-07-06'} | ${15} | ${'2035-07-06'}
`(
'returns $expected for "$numberOfYears year(s) after $date"',
({ date, numberOfYears, expected }) => {
expect(datetimeUtility.nYearsAfter(new Date(date), numberOfYears)).toEqual(
new Date(expected),
);
},
);
});
describe('nMonthsBefore', () => { describe('nMonthsBefore', () => {
// The previous month (February) has 28 days // The previous month (February) has 28 days
const march2019 = '2019-03-15T00:00:00.000Z'; const march2019 = '2019-03-15T00:00:00.000Z';
...@@ -1018,6 +1033,81 @@ describe('isToday', () => { ...@@ -1018,6 +1033,81 @@ describe('isToday', () => {
}); });
}); });
describe('isInPast', () => {
it.each`
date | expected
${new Date('2024-12-15')} | ${false}
${new Date('2020-07-06T00:00')} | ${false}
${new Date('2020-07-05T23:59:59.999')} | ${true}
${new Date('2020-07-05')} | ${true}
${new Date('1999-03-21')} | ${true}
`('returns $expected for $date', ({ date, expected }) => {
expect(datetimeUtility.isInPast(date)).toBe(expected);
});
});
describe('isInFuture', () => {
it.each`
date | expected
${new Date('2024-12-15')} | ${true}
${new Date('2020-07-07T00:00')} | ${true}
${new Date('2020-07-06T23:59:59.999')} | ${false}
${new Date('2020-07-06')} | ${false}
${new Date('1999-03-21')} | ${false}
`('returns $expected for $date', ({ date, expected }) => {
expect(datetimeUtility.isInFuture(date)).toBe(expected);
});
});
describe('fallsBefore', () => {
it.each`
dateA | dateB | expected
${new Date('2020-07-06T23:59:59.999')} | ${new Date('2020-07-07T00:00')} | ${true}
${new Date('2020-07-07T00:00')} | ${new Date('2020-07-06T23:59:59.999')} | ${false}
${new Date('2020-04-04')} | ${new Date('2021-10-10')} | ${true}
${new Date('2021-10-10')} | ${new Date('2020-04-04')} | ${false}
`('returns $expected for "$dateA falls before $dateB"', ({ dateA, dateB, expected }) => {
expect(datetimeUtility.fallsBefore(dateA, dateB)).toBe(expected);
});
});
describe('removeTime', () => {
it.each`
date | expected
${new Date('2020-07-07')} | ${new Date('2020-07-07T00:00:00.000')}
${new Date('2020-07-07T00:00:00.001')} | ${new Date('2020-07-07T00:00:00.000')}
${new Date('2020-07-07T23:59:59.999')} | ${new Date('2020-07-07T00:00:00.000')}
${new Date('2020-07-07T12:34:56.789')} | ${new Date('2020-07-07T00:00:00.000')}
`('returns $expected for $date', ({ date, expected }) => {
expect(datetimeUtility.removeTime(date)).toEqual(expected);
});
});
describe('getTimeRemainingInWords', () => {
it.each`
date | expected
${new Date('2020-07-06T12:34:56.789')} | ${'0 days remaining'}
${new Date('2020-07-07T12:34:56.789')} | ${'1 day remaining'}
${new Date('2020-07-08T12:34:56.789')} | ${'2 days remaining'}
${new Date('2020-07-12T12:34:56.789')} | ${'6 days remaining'}
${new Date('2020-07-13T12:34:56.789')} | ${'1 week remaining'}
${new Date('2020-07-19T12:34:56.789')} | ${'1 week remaining'}
${new Date('2020-07-20T12:34:56.789')} | ${'2 weeks remaining'}
${new Date('2020-07-27T12:34:56.789')} | ${'3 weeks remaining'}
${new Date('2020-08-03T12:34:56.789')} | ${'4 weeks remaining'}
${new Date('2020-08-05T12:34:56.789')} | ${'4 weeks remaining'}
${new Date('2020-08-06T12:34:56.789')} | ${'1 month remaining'}
${new Date('2020-09-06T12:34:56.789')} | ${'2 months remaining'}
${new Date('2021-06-06T12:34:56.789')} | ${'11 months remaining'}
${new Date('2021-07-06T12:34:56.789')} | ${'1 year remaining'}
${new Date('2022-07-06T12:34:56.789')} | ${'2 years remaining'}
${new Date('2030-07-06T12:34:56.789')} | ${'10 years remaining'}
${new Date('2119-07-06T12:34:56.789')} | ${'99 years remaining'}
`('returns $expected for $date', ({ date, expected }) => {
expect(datetimeUtility.getTimeRemainingInWords(date)).toEqual(expected);
});
});
describe('getStartOfDay', () => { describe('getStartOfDay', () => {
beforeEach(() => { beforeEach(() => {
timezoneMock.register('US/Eastern'); timezoneMock.register('US/Eastern');
......
...@@ -252,6 +252,10 @@ RSpec.configure do |config| ...@@ -252,6 +252,10 @@ RSpec.configure do |config|
# https://gitlab.com/groups/gitlab-org/-/epics/5501 # https://gitlab.com/groups/gitlab-org/-/epics/5501
stub_feature_flags(boards_filtered_search: false) stub_feature_flags(boards_filtered_search: false)
# The following `vue_issues_list` stub can be removed once the
# Vue issues page has feature parity with the current Haml page
stub_feature_flags(vue_issues_list: false)
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged) allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else else
unstub_all_feature_flags unstub_all_feature_flags
......
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