+
+
{{ getFormattedStatus }}
diff --git a/ee/spec/frontend/issues/components/blocking_issues_count_spec.js b/ee/spec/frontend/issues/components/blocking_issues_count_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..6b65e7d4f37302f37e7723b7ebb79cf960528385
--- /dev/null
+++ b/ee/spec/frontend/issues/components/blocking_issues_count_spec.js
@@ -0,0 +1,69 @@
+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('');
+ });
+ });
+});
diff --git a/ee/spec/frontend/issues/components/weight_count_spec.js b/ee/spec/frontend/issues/components/weight_count_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..597ccb7d94ddcd0155abf3d4d94b61547ab53466
--- /dev/null
+++ b/ee/spec/frontend/issues/components/weight_count_spec.js
@@ -0,0 +1,55 @@
+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('');
+ });
+ });
+});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 53ba4f7ba299c20967fe3f181cf5eb73855b2953..87bf1b3e0fa09db272dc258f2d1cf10bea21e14e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -409,6 +409,11 @@ msgstr ""
msgid "%{completedCount} completed weight"
msgstr ""
+msgid "%{completedCount} of %{count} task completed"
+msgid_plural "%{completedCount} of %{count} tasks completed"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%{completedWeight} of %{totalWeight} weight completed"
msgstr ""
@@ -1151,6 +1156,11 @@ msgid_plural "%d days"
msgstr[0] ""
msgstr[1] ""
+msgid "1 day remaining"
+msgid_plural "%d days remaining"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "1 deploy key"
msgid_plural "%d deploy keys"
msgstr[0] ""
@@ -1191,6 +1201,11 @@ msgid_plural "%d minutes"
msgstr[0] ""
msgstr[1] ""
+msgid "1 month remaining"
+msgid_plural "%d months remaining"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "1 open issue"
msgid_plural "%{issues} open issues"
msgstr[0] ""
@@ -1216,6 +1231,16 @@ msgid_plural "%{num} users"
msgstr[0] ""
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"
msgstr ""
diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js
index dde65ace9dbdd843e3ec746f014f0020c4b44607..7281d2fde1d4b483a26ed0af9187f3fa0c5e8223 100644
--- a/spec/frontend/issuable_list/components/issuable_item_spec.js
+++ b/spec/frontend/issuable_list/components/issuable_item_spec.js
@@ -294,7 +294,17 @@ describe('IssuableItem', () => {
expect(confidentialEl.exists()).toBe(true);
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', () => {
diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js
index e19a337473adba9256bdccb85334c2965cd34c38..33ffd60bf95c20fef2ca19506a8b8017a3db63e1 100644
--- a/spec/frontend/issuable_list/mock_data.js
+++ b/spec/frontend/issuable_list/mock_data.js
@@ -53,6 +53,10 @@ export const mockIssuable = {
},
assignees: [mockAuthor],
userDiscussionsCount: 2,
+ taskCompletionStatus: {
+ count: 2,
+ completedCount: 1,
+ },
};
export const mockIssuables = [
diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..614ad586ec9bd1eacc5839a7de35212733b95c0d
--- /dev/null
+++ b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
@@ -0,0 +1,109 @@
+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');
+ });
+});
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..1053e8934c97e59ed087a796a39ce2f0421266d5
--- /dev/null
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -0,0 +1,98 @@
+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 },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index 32a24227cbd11e35a40e6938f338ca8d8f398955..cfb88820c7de4ef3a71ef4687b0a6c6ce8c779db 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -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', () => {
// The previous month (February) has 28 days
const march2019 = '2019-03-15T00:00:00.000Z';
@@ -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', () => {
beforeEach(() => {
timezoneMock.register('US/Eastern');
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 87bb3ea842caba8d661ab34e1292e8674555a2eb..60a8fb8cb9f37a814e2d74884d134ecd19a74eab 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -252,6 +252,10 @@
# https://gitlab.com/groups/gitlab-org/-/epics/5501
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)
else
unstub_all_feature_flags