diff options
Diffstat (limited to 'app/assets/javascripts/issues_list/components/issuable.vue')
-rw-r--r-- | app/assets/javascripts/issues_list/components/issuable.vue | 432 |
1 files changed, 432 insertions, 0 deletions
diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue new file mode 100644 index 00000000000..adfb234fe7a --- /dev/null +++ b/app/assets/javascripts/issues_list/components/issuable.vue @@ -0,0 +1,432 @@ +<script> +/* + * This is tightly coupled to projects/issues/_issue.html.haml, + * any changes done to the haml need to be reflected here. + */ + +// TODO: need to move this component to graphql - https://gitlab.com/gitlab-org/gitlab/-/issues/221246 +import { escape, isNumber } from 'lodash'; +import { + GlLink, + GlTooltipDirective as GlTooltip, + GlSprintf, + GlLabel, + GlIcon, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg'; +import { + dateInWords, + formatDate, + getDayDifference, + getTimeago, + timeFor, + newDateAsLocaleTime, +} from '~/lib/utils/datetime_utility'; +import { sprintf, __ } from '~/locale'; +import initUserPopovers from '~/user_popovers'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +import { convertToCamelCase } from '~/lib/utils/text_utility'; + +export default { + i18n: { + openedAgo: __('opened %{timeAgoString} by %{user}'), + openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'), + }, + components: { + IssueAssignees, + GlLink, + GlLabel, + GlIcon, + GlSprintf, + IssueHealthStatus: () => + import('ee_component/related_items_tree/components/issue_health_status.vue'), + }, + directives: { + GlTooltip, + SafeHtml, + }, + mixins: [glFeatureFlagsMixin()], + props: { + issuable: { + type: Object, + required: true, + }, + isBulkEditing: { + type: Boolean, + required: false, + default: false, + }, + selected: { + type: Boolean, + required: false, + default: false, + }, + baseUrl: { + type: String, + required: false, + default() { + return window.location.href; + }, + }, + }, + data() { + return { + jiraLogo, + }; + }, + computed: { + milestoneLink() { + const { title } = this.issuable.milestone; + + return this.issuableLink({ milestone_title: title }); + }, + scopedLabelsAvailable() { + return this.glFeatures.scopedLabels; + }, + hasWeight() { + return isNumber(this.issuable.weight); + }, + dueDate() { + return this.issuable.due_date ? newDateAsLocaleTime(this.issuable.due_date) : undefined; + }, + dueDateWords() { + return this.dueDate ? dateInWords(this.dueDate, true) : undefined; + }, + isOverdue() { + return this.dueDate ? this.dueDate < new Date() : false; + }, + isClosed() { + return this.issuable.state === 'closed'; + }, + isJiraIssue() { + return this.issuable.external_tracker === 'jira'; + }, + linkTarget() { + return this.isJiraIssue ? '_blank' : null; + }, + issueCreatedToday() { + return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1; + }, + labelIdsString() { + return JSON.stringify(this.issuable.labels.map(l => l.id)); + }, + milestoneDueDate() { + const { due_date: dueDate } = this.issuable.milestone || {}; + + return dueDate ? newDateAsLocaleTime(dueDate) : undefined; + }, + milestoneTooltipText() { + if (this.milestoneDueDate) { + return sprintf(__('%{primary} (%{secondary})'), { + primary: formatDate(this.milestoneDueDate, 'mmm d, yyyy'), + secondary: timeFor(this.milestoneDueDate), + }); + } + return __('Milestone'); + }, + issuableAuthor() { + return this.issuable.author; + }, + issuableCreatedAt() { + return getTimeago().format(this.issuable.created_at); + }, + popoverDataAttrs() { + const { id, username, name, avatar_url } = this.issuableAuthor; + + return { + 'data-user-id': id, + 'data-username': username, + 'data-name': name, + 'data-avatar-url': avatar_url, + }; + }, + referencePath() { + return this.issuable.references.relative; + }, + updatedDateString() { + return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt'); + }, + updatedDateAgo() { + // snake_case because it's the same i18n string as the HAML view + return sprintf(__('updated %{time_ago}'), { + time_ago: escape(getTimeago().format(this.issuable.updated_at)), + }); + }, + issuableMeta() { + return [ + { + key: 'merge-requests', + visible: this.issuable.merge_requests_count > 0, + value: this.issuable.merge_requests_count, + title: __('Related merge requests'), + dataTestId: 'merge-requests', + class: 'js-merge-requests', + icon: 'merge-request', + }, + { + key: 'upvotes', + visible: this.issuable.upvotes > 0, + value: this.issuable.upvotes, + title: __('Upvotes'), + dataTestId: 'upvotes', + class: 'js-upvotes issuable-upvotes', + icon: 'thumb-up', + }, + { + key: 'downvotes', + visible: this.issuable.downvotes > 0, + value: this.issuable.downvotes, + title: __('Downvotes'), + dataTestId: 'downvotes', + class: 'js-downvotes issuable-downvotes', + icon: 'thumb-down', + }, + { + key: 'blocking-issues', + visible: this.issuable.blocking_issues_count > 0, + value: this.issuable.blocking_issues_count, + title: __('Blocking issues'), + dataTestId: 'blocking-issues', + href: `${this.issuable.web_url}#related-issues`, + icon: 'issue-block', + }, + { + key: 'comments-count', + visible: !this.isJiraIssue, + value: this.issuable.user_notes_count, + title: __('Comments'), + dataTestId: 'notes-count', + href: `${this.issuable.web_url}#notes`, + class: { 'no-comments': !this.issuable.user_notes_count, 'issuable-comments': true }, + icon: 'comments', + }, + ]; + }, + healthStatus() { + return convertToCamelCase(this.issuable.health_status); + }, + }, + mounted() { + // TODO: Refactor user popover to use its own component instead of + // spawning event listeners on Vue-rendered elements. + initUserPopovers([this.$refs.openedAgoByContainer.$el]); + }, + methods: { + issuableLink(params) { + return mergeUrlParams(params, this.baseUrl); + }, + isScoped({ name }) { + return isScopedLabel({ title: name }) && this.scopedLabelsAvailable; + }, + labelHref({ name }) { + if (this.isJiraIssue) { + return this.issuableLink({ 'labels[]': name }); + } + + return this.issuableLink({ 'label_name[]': name }); + }, + onSelect(ev) { + this.$emit('select', { + issuable: this.issuable, + selected: ev.target.checked, + }); + }, + issuableMetaComponent(href) { + return href ? 'gl-link' : 'span'; + }, + }, + + confidentialTooltipText: __('Confidential'), +}; +</script> +<template> + <li + :id="`issue_${issuable.id}`" + class="issue" + :class="{ today: issueCreatedToday, closed: isClosed }" + :data-id="issuable.id" + :data-labels="labelIdsString" + :data-url="issuable.web_url" + data-qa-selector="issue_container" + :data-qa-issue-title="issuable.title" + > + <div class="gl-display-flex"> + <!-- Bulk edit checkbox --> + <div v-if="isBulkEditing" class="gl-mr-3"> + <input + :id="`selected_issue_${issuable.id}`" + :checked="selected" + class="selected-issuable" + type="checkbox" + :data-id="issuable.id" + @input="onSelect" + /> + </div> + + <!-- Issuable info container --> + <!-- Issuable main info --> + <div class="gl-flex-grow-1"> + <div class="title"> + <span class="issue-title-text"> + <gl-icon + v-if="issuable.confidential" + v-gl-tooltip + name="eye-slash" + class="gl-vertical-align-text-bottom" + :size="16" + :title="$options.confidentialTooltipText" + :aria-label="$options.confidentialTooltipText" + /> + <gl-link + :href="issuable.web_url" + :target="linkTarget" + data-testid="issuable-title" + data-qa-selector="issue_link" + >{{ issuable.title + }}<gl-icon + v-if="isJiraIssue" + name="external-link" + class="gl-vertical-align-text-bottom gl-ml-2" + /> + </gl-link> + </span> + <span + v-if="issuable.has_tasks" + class="gl-ml-2 task-status gl-display-none d-sm-inline-block" + >{{ issuable.task_status }}</span + > + </div> + + <div class="issuable-info"> + <span class="js-ref-path gl-mr-4 mr-sm-0"> + <span + v-if="isJiraIssue" + v-safe-html="jiraLogo" + class="svg-container jira-logo-container" + data-testid="jira-logo" + ></span> + {{ referencePath }} + </span> + + <span data-testid="openedByMessage" class="gl-display-none d-sm-inline-block gl-mr-4"> + · + <gl-sprintf + :message="isJiraIssue ? $options.i18n.openedAgoJira : $options.i18n.openedAgo" + > + <template #timeAgoString> + <span>{{ issuableCreatedAt }}</span> + </template> + <template #user> + <gl-link + ref="openedAgoByContainer" + v-bind="popoverDataAttrs" + :href="issuableAuthor.web_url" + :target="linkTarget" + >{{ issuableAuthor.name }}</gl-link + > + </template> + </gl-sprintf> + </span> + + <gl-link + v-if="issuable.milestone" + v-gl-tooltip + class="gl-display-none d-sm-inline-block gl-mr-4 js-milestone milestone" + :href="milestoneLink" + :title="milestoneTooltipText" + > + <gl-icon name="clock" class="s16 gl-vertical-align-text-bottom" /> + {{ issuable.milestone.title }} + </gl-link> + + <span + v-if="dueDate" + v-gl-tooltip + class="gl-display-none d-sm-inline-block gl-mr-4 js-due-date" + :class="{ cred: isOverdue }" + :title="__('Due date')" + > + <i class="fa fa-calendar"></i> + {{ dueDateWords }} + </span> + + <span + v-if="hasWeight" + v-gl-tooltip + :title="__('Weight')" + class="gl-display-none d-sm-inline-block gl-mr-4" + data-testid="weight" + data-qa-selector="issuable_weight_content" + > + <gl-icon name="weight" class="align-text-bottom" /> + {{ issuable.weight }} + </span> + + <issue-health-status + v-if="issuable.health_status" + :health-status="healthStatus" + class="gl-mr-4 issuable-tag-valign" + /> + + <gl-label + v-for="label in issuable.labels" + :key="label.id" + data-qa-selector="issuable-label" + :target="labelHref(label)" + :background-color="label.color" + :description="label.description" + :color="label.text_color" + :title="label.name" + :scoped="isScoped(label)" + size="sm" + class="gl-mr-2 issuable-tag-valign" + >{{ label.name }}</gl-label + > + </div> + </div> + + <!-- Issuable meta --> + <div + class="gl-flex-shrink-0 gl-display-flex gl-flex-direction-column align-items-end gl-justify-content-center" + > + <div class="controls gl-display-flex"> + <span v-if="isJiraIssue" data-testid="issuable-status">{{ issuable.status }}</span> + <span v-else-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> + + <issue-assignees + :assignees="issuable.assignees" + class="gl-align-items-center gl-display-flex gl-ml-3" + :icon-size="16" + img-css-classes="gl-mr-2!" + :max-visible="4" + /> + + <template v-for="meta in issuableMeta"> + <span + v-if="meta.visible" + :key="meta.key" + v-gl-tooltip + class="gl-display-none gl-display-sm-flex gl-align-items-center gl-ml-3" + :class="meta.class" + :data-testid="meta.dataTestId" + :title="meta.title" + > + <component :is="issuableMetaComponent(meta.href)" :href="meta.href"> + <gl-icon v-if="meta.icon" :name="meta.icon" /> + {{ meta.value }} + </component> + </span> + </template> + </div> + <div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString"> + {{ updatedDateAgo }} + </div> + </div> + </div> + </li> +</template> |