diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index f4d2312c70deb2afc8c742213fadfaa8181160de..92c527c79ffc3d9a08fec01abd0c44adf964f3e6 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'; @@ -86,8 +86,26 @@ export default { } 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() { - return typeof this.issuable.userDiscussionsCount === 'number'; + return typeof this.notesCount === 'number'; }, showIssuableMeta() { return Boolean( @@ -148,19 +166,27 @@ export default { v-gl-tooltip name="eye-slash" :title="__('Confidential')" + :aria-label="__('Confidential')" /> {{ issuable.title }} + + {{ taskStatus }} +
{{ issuableSymbol }}{{ issuable.iid }} - + · +
  • + +
  • +
  • - {{ issuable.userDiscussionsCount }} + {{ notesCount }}
  • -
  • - -
  • + 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 0000000000000000000000000000000000000000..8d00d337baca333a57c0a816f19d80095f6defe8 --- /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 new file mode 100644 index 0000000000000000000000000000000000000000..c57fa5a82fa421a665598ec60c95c1463f6567ae --- /dev/null +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -0,0 +1,143 @@ + + + diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 295d44648664f6fe55cd130f13e852a8e85f4945..a283cbdc86bd3f40adaf8c4372024854a5564477 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -1,7 +1,8 @@ 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 { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import IssuablesListApp from './components/issuables_list_app.vue'; import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue'; @@ -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() { mountJiraIssuesListApp(); mountIssuablesListApp(); diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 38b3b26dc4433b277d5178c06ad363727bdaa954..7073b34988f1e4568ba81b41b1e9e08654d9f63a 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 a3a053c3c3172550197f4e2c3f07f6ab9d813d21..366f8dc61bcb0efa6f6ee2559584a5387ec7771e 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/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 3fe9e1203ecc99ca18c535f5292e12710f270a27..1d300c42768213cb8fd53c1309d54588d8042356 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -17,16 +17,25 @@ .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, + 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 - = 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 0000000000000000000000000000000000000000..bc5537c1f402b99c3f25d3b451f6c54b385981a1 --- /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/issues/components/blocking_issues_count.vue b/ee/app/assets/javascripts/issues/components/blocking_issues_count.vue new file mode 100644 index 0000000000000000000000000000000000000000..fdb7a9bba7cead8b4d04b622070a5aa77e848ed5 --- /dev/null +++ b/ee/app/assets/javascripts/issues/components/blocking_issues_count.vue @@ -0,0 +1,44 @@ + + + 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 0000000000000000000000000000000000000000..ba26a36874936b66fa6a9b442bb75ac1cf539dfc --- /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 4ccacafbc56c7b6a2e926dd2dfdb67c64e5f21dd..e67527a31d07c056940230f39bce801669efddb9 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 @@ -22,8 +22,8 @@ export default {