Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/issues_list/components/issuable.vue')
-rw-r--r--app/assets/javascripts/issues_list/components/issuable.vue432
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">
+ &middot;
+ <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>