From f4d68fd87405de24d421c8c66c6cd7970adf8bfa Mon Sep 17 00:00:00 2001 From: Coung Ngo Date: Mon, 8 Mar 2021 13:15:21 +0000 Subject: [PATCH 1/6] 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 --- .../components/issuable_item.vue | 200 ++++++++++++++++-- .../javascripts/issue_show/constants.js | 12 ++ .../components/issues_list_app.vue | 97 +++++++++ app/assets/javascripts/issues_list/index.js | 20 ++ .../javascripts/lib/utils/datetime_utility.js | 85 ++++++++ .../pages/projects/issues/index/index.js | 3 +- app/assets/stylesheets/utilities.scss | 14 ++ app/views/projects/issues/index.html.haml | 21 +- .../development/vue_issues_list.yml | 8 + .../components/issue_health_status.vue | 2 +- .../related_items_tree/constants.js | 14 +- .../components/issue_health_status_spec.js | 2 +- locale/gitlab.pot | 26 +++ .../components/issuable_item_spec.js | 85 +++++++- spec/frontend/issuable_list/mock_data.js | 20 ++ .../components/issues_list_app_spec.js | 98 +++++++++ .../lib/utils/datetime_utility_spec.js | 90 ++++++++ spec/spec_helper.rb | 4 + 18 files changed, 759 insertions(+), 42 deletions(-) create mode 100644 app/assets/javascripts/issues_list/components/issues_list_app.vue create mode 100644 config/feature_flags/development/vue_issues_list.yml create mode 100644 spec/frontend/issues_list/components/issues_list_app_spec.js diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index f4d2312c70deb2..42efc3ef2e8ef1 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -2,8 +2,17 @@ import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { issueHealthStatus, issueHealthStatusCSSMapping } from '~/issue_show/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import { getTimeago } from '~/lib/utils/datetime_utility'; +import { + dateInWords, + getTimeago, + getTimeRemainingInWords, + isInFuture, + isInPast, + isToday, +} from '~/lib/utils/datetime_utility'; +import { convertToCamelCase } from '~/lib/utils/text_utility'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; @@ -86,8 +95,51 @@ export default { } return {}; }, + taskStatus() { + const { completedCount, count } = this.issuable.taskCompletionStatus || {}; + if (!count) { + return undefined; + } + + return count === 1 + ? sprintf(__('%{completedCount} of %{count} task completed'), { completedCount, count }) + : sprintf(__('%{completedCount} of %{count} tasks completed'), { completedCount, count }); + }, + milestoneDate() { + if (this.issuable.milestone?.dueDate) { + const { dueDate, startDate } = this.issuable.milestone; + const date = dateInWords(new Date(dueDate), true); + const remainingTime = this.milestoneRemainingTime(dueDate, startDate); + return `${date} (${remainingTime})`; + } + return __('Milestone'); + }, + dueDate() { + return this.issuable.dueDate && dateInWords(new Date(this.issuable.dueDate), true); + }, + isDueDateInPast() { + return isInPast(new Date(this.issuable.dueDate)); + }, + healthStatusClass() { + return ( + this.issuable.healthStatus && + issueHealthStatusCSSMapping[convertToCamelCase(this.issuable.healthStatus)] + ); + }, + healthStatusText() { + return ( + this.issuable.healthStatus && + issueHealthStatus[convertToCamelCase(this.issuable.healthStatus)] + ); + }, + timeEstimate() { + return this.issuable.timeStats?.humanTimeEstimate; + }, + notesCount() { + return this.issuable.userDiscussionsCount ?? this.issuable.userNotesCount; + }, showDiscussions() { - return typeof this.issuable.userDiscussionsCount === 'number'; + return typeof this.notesCount === 'number'; }, showIssuableMeta() { return Boolean( @@ -126,6 +178,21 @@ export default { } return ''; }, + 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 ''; + }, }, }; @@ -148,19 +215,27 @@ export default { v-gl-tooltip name="eye-slash" :title="__('Confidential')" + :aria-label="__('Confidential')" /> {{ issuable.title }} + + {{ taskStatus }} +
{{ issuableSymbol }}{{ issuable.iid }} - + · {{ author.name }} + + + + {{ issuable.milestone.title }} + + + + + {{ dueDate }} + + + + {{ issuable.weight }} + + + + + {{ healthStatusText }} + + + -   + + + {{ timeEstimate }} +
@@ -203,6 +329,55 @@ export default {
  • +
  • + +
  • +
  • + + {{ issuable.mergeRequestsCount }} +
  • +
  • + + {{ issuable.upvotes }} +
  • +
  • + + {{ issuable.downvotes }} +
  • +
  • + + {{ issuable.blockingIssuesCount }} +
  • - {{ issuable.userDiscussionsCount }} + {{ notesCount }}
  • -
  • - -
  • +import { toNumber } from 'lodash'; +import createFlash from '~/flash'; +import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; +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'; + +export default { + PAGE_SIZE, + components: { + IssuableList, + }, + props: { + endpoint: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + }, + data() { + return { + currentPage: toNumber(getParameterByName('page')) || 1, + isLoading: false, + issues: [], + totalIssues: 0, + }; + }, + computed: { + urlParams() { + return { + page: this.currentPage, + state: 'opened', + }; + }, + }, + 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: 'opened', + 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); + }, + }, +}; + + + diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 295d44648664f6..78fe2dbf5ee8d6 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import IssuablesListApp from './components/issuables_list_app.vue'; @@ -64,6 +65,25 @@ function mountIssuablesListApp() { }); } +export function initIssuesListApp() { + const el = document.querySelector('.js-issues-list'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: (createComponent) => + createComponent(IssuesListApp, { + props: { + endpoint: el.dataset.endpoint, + fullPath: el.dataset.fullPath, + }, + }), + }); +} + export default function initIssuablesList() { mountJiraIssuesListApp(); mountIssuablesListApp(); diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 38b3b26dc4433b..7073b34988f1e4 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -768,6 +768,19 @@ export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => { 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 * @@ -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 * diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index a3a053c3c31725..366f8dc61bcb0e 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -5,7 +5,7 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons'; import initIssuableByEmail from '~/issuable/init_issuable_by_email'; import IssuableIndex from '~/issuable_index'; -import initIssuablesList from '~/issues_list'; +import initIssuablesList, { initIssuesListApp } from '~/issues_list'; import initManualOrdering from '~/manual_ordering'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; @@ -28,3 +28,4 @@ initManualOrdering(); initIssuablesList(); initIssuableByEmail(); initCsvImportExportButtons(); +initIssuesListApp(); diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 024162eba3eb61..553ce1a19bb826 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -172,3 +172,17 @@ width: 50%; } } + +// Adding to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2071 +.gl-sm-display-inline-block { + @media (min-width: $breakpoint-sm) { + display: inline-block; + } +} + +// Adding to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2071 +.gl-sm-display-inline-block\! { + @media (min-width: $breakpoint-sm) { + display: inline-block !important; + } +} diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 3fe9e1203ecc99..ecc63080518669 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -17,16 +17,21 @@ .top-area = render 'shared/issuable/nav', type: :issues = render "projects/issues/nav_btns" - = render 'shared/issuable/search_bar', type: :issues - - if @can_bulk_update - = render 'shared/issuable/bulk_update_sidebar', type: :issues + - if Feature.enabled?(:vue_issues_list, @project) + - 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 } } + - else + = render 'shared/issuable/search_bar', type: :issues - .issues-holder - = 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) } } + - if @can_bulk_update + = render 'shared/issuable/bulk_update_sidebar', type: :issues + + .issues-holder + = 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 - 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 diff --git a/config/feature_flags/development/vue_issues_list.yml b/config/feature_flags/development/vue_issues_list.yml new file mode 100644 index 00000000000000..bc5537c1f402b9 --- /dev/null +++ b/config/feature_flags/development/vue_issues_list.yml @@ -0,0 +1,8 @@ +--- +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 diff --git a/ee/app/assets/javascripts/related_items_tree/components/issue_health_status.vue b/ee/app/assets/javascripts/related_items_tree/components/issue_health_status.vue index 4ccacafbc56c7b..78e75a4a715f59 100644 --- a/ee/app/assets/javascripts/related_items_tree/components/issue_health_status.vue +++ b/ee/app/assets/javascripts/related_items_tree/components/issue_health_status.vue @@ -1,5 +1,5 @@ + + diff --git a/ee/app/assets/javascripts/issues/components/weight_count.vue b/ee/app/assets/javascripts/issues/components/weight_count.vue new file mode 100644 index 00000000000000..ba26a36874936b --- /dev/null +++ b/ee/app/assets/javascripts/issues/components/weight_count.vue @@ -0,0 +1,36 @@ + + + diff --git a/ee/app/assets/javascripts/related_items_tree/components/issue_health_status.vue b/ee/app/assets/javascripts/related_items_tree/components/issue_health_status.vue index 78e75a4a715f59..e67527a31d07c0 100644 --- a/ee/app/assets/javascripts/related_items_tree/components/issue_health_status.vue +++ b/ee/app/assets/javascripts/related_items_tree/components/issue_health_status.vue @@ -1,5 +1,5 @@ + diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue new file mode 100644 index 00000000000000..8d00d337baca33 --- /dev/null +++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue @@ -0,0 +1,124 @@ + + + diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue index 8d2ec1b365c9dc..87ad6b3b2e4b4a 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -1,4 +1,5 @@ diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js index 1d609331b357bd..7281d2fde1d4b4 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -3,14 +3,10 @@ import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; import IssuableItem from '~/issuable_list/components/issuable_item.vue'; import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; + import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data'; -const createComponent = ({ - issuableSymbol = '#', - issuable = mockIssuable, - slots = {}, - props = {}, -} = {}) => +const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable, slots = {} } = {}) => shallowMount(IssuableItem, { propsData: { issuableSymbol, @@ -18,10 +14,6 @@ const createComponent = ({ enableLabelPermalinks: true, showDiscussions: true, showCheckbox: false, - ...props, - }, - provide: { - hasIssuableHealthStatusFeature: true, }, slots, }); @@ -380,33 +372,6 @@ describe('IssuableItem', () => { wrapperWithAuthorSlot.destroy(); }); - it('renders milestone', () => { - const milestone = wrapper.find('[data-testid="issuable-milestone"]'); - - expect(milestone.text()).toBe(mockIssuable.milestone.title); - expect(milestone.find(GlIcon).props('name')).toBe('clock'); - expect(milestone.find(GlLink).attributes()).toMatchObject({ - href: mockIssuable.milestone.webUrl, - title: 'Dec 17, 2020 (6 days remaining)', - }); - }); - - it('renders due date', () => { - const dueDate = wrapper.find('[data-testid="issuable-due-date"]'); - - expect(dueDate.text()).toBe('Dec 12, 2020'); - expect(dueDate.find(GlIcon).props('name')).toBe('calendar'); - expect(dueDate.attributes('title')).toBe('Due date'); - }); - - it('renders time estimate', () => { - const timeEstimate = wrapper.find('[data-testid="time-estimate"]'); - - expect(timeEstimate.text()).toBe(mockIssuable.timeStats.humanTimeEstimate); - expect(timeEstimate.find(GlIcon).props('name')).toBe('timer'); - expect(timeEstimate.attributes('title')).toBe('Estimate'); - }); - it('renders timeframe via slot', () => { const wrapperWithTimeframeSlot = createComponent({ issuableSymbol: '#', @@ -481,30 +446,6 @@ describe('IssuableItem', () => { }); }); - it('renders merge request count', () => { - const mergeRequestsCount = wrapper.find('[data-testid="issuable-mr"]'); - - expect(mergeRequestsCount.text()).toBe(mockIssuable.mergeRequestsCount.toString()); - expect(mergeRequestsCount.find(GlIcon).props('name')).toBe('merge-request'); - expect(mergeRequestsCount.attributes('title')).toBe('Related merge requests'); - }); - - it('renders upvotes count', () => { - const upvotes = wrapper.find('[data-testid="issuable-upvotes"]'); - - expect(upvotes.text()).toBe(mockIssuable.upvotes.toString()); - expect(upvotes.find(GlIcon).props('name')).toBe('thumb-up'); - expect(upvotes.attributes('title')).toBe('Upvotes'); - }); - - it('renders downvotes count', () => { - const downvotes = wrapper.find('[data-testid="issuable-downvotes"]'); - - expect(downvotes.text()).toBe(mockIssuable.downvotes.toString()); - expect(downvotes.find(GlIcon).props('name')).toBe('thumb-down'); - expect(downvotes.attributes('title')).toBe('Downvotes'); - }); - it('renders issuable updatedAt info', () => { const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js index 2db4115ee9d0fb..33ffd60bf95c20 100644 --- a/spec/frontend/issuable_list/mock_data.js +++ b/spec/frontend/issuable_list/mock_data.js @@ -57,22 +57,6 @@ export const mockIssuable = { count: 2, completedCount: 1, }, - milestone: { - dueDate: '2020-12-17', - startDate: '2020-12-10', - title: 'My milestone', - webUrl: '/milestone/webUrl', - }, - dueDate: '2020-12-12', - weight: 0, - healthStatus: 'at_risk', - timeStats: { - humanTimeEstimate: '1w', - }, - mergeRequestsCount: 2, - upvotes: 5, - downvotes: 1, - blockingIssuesCount: 4, }; 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 00000000000000..d033856429fbf2 --- /dev/null +++ b/spec/frontend/issues_list/components/issue_card_time_info_spec.js @@ -0,0 +1,130 @@ +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('when due date is in past', () => { + it('renders with "Past due"', () => { + wrapper = mountComponent({ milestoneDueDate: '2020-09-09' }); + + expect(findMilestoneTitle()).toBe('Sep 9, 2020 (Past due)'); + }); + }); + + describe('when due date is today', () => { + it('renders with "Today"', () => { + wrapper = mountComponent({ milestoneDueDate: '2020-12-11' }); + + expect(findMilestoneTitle()).toBe('Dec 11, 2020 (Today)'); + }); + }); + + describe('when start date is in future', () => { + it('renders with "Upcoming"', () => { + wrapper = mountComponent({ + milestoneStartDate: '2021-02-01', + milestoneDueDate: '2021-03-01', + }); + + expect(findMilestoneTitle()).toBe('Mar 1, 2021 (Upcoming)'); + }); + }); + + describe('when due date is in future', () => { + it('renders with time remaining', () => { + wrapper = mountComponent({ milestoneDueDate: '2020-12-25' }); + + expect(findMilestoneTitle()).toBe('Dec 25, 2020 (2 weeks remaining)'); + }); + }); + }); + + 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'); + }); +}); -- GitLab From 01fcd76d83a2f971804a24b36693ca76200db753 Mon Sep 17 00:00:00 2001 From: Coung Ngo Date: Fri, 12 Mar 2021 11:33:35 +0000 Subject: [PATCH 6/6] Add Kushal suggestions Add Kushal suggestions --- .../components/issuable_item.vue | 15 ++++--- .../components/issuable_list_root.vue | 4 +- .../components/issues_list_app.vue | 8 ++-- .../components/blocking_issues_count_spec.js | 14 +----- .../issues/components/weight_count_spec.js | 18 ++------ locale/gitlab.pot | 7 ++- .../components/issue_card_time_info_spec.js | 43 +++++-------------- 7 files changed, 35 insertions(+), 74 deletions(-) diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 83944f275c272c..92c527c79ffc3d 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -5,7 +5,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { getTimeago } from '~/lib/utils/datetime_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 timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -92,9 +92,14 @@ export default { return undefined; } - return count === 1 - ? sprintf(__('%{completedCount} of %{count} task completed'), { completedCount, count }) - : sprintf(__('%{completedCount} of %{count} tasks completed'), { completedCount, count }); + return sprintf( + n__( + '%{completedCount} of %{count} task completed', + '%{completedCount} of %{count} tasks completed', + count, + ), + { completedCount, count }, + ); }, notesCount() { return this.issuable.userDiscussionsCount ?? this.issuable.userNotesCount; @@ -233,7 +238,7 @@ export default { class="gl-align-items-center gl-display-flex gl-ml-3" /> - +
  • -