diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue')
-rw-r--r-- | app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue | 303 |
1 files changed, 303 insertions, 0 deletions
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue new file mode 100644 index 00000000000..0bb0e0d9fb0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -0,0 +1,303 @@ +<script> +import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; + +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility'; +import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; +import { __, n__, sprintf } from '~/locale'; +import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + components: { + GlLink, + GlIcon, + GlLabel, + GlFormCheckbox, + GlSprintf, + IssuableAssignees, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + issuableSymbol: { + type: String, + required: true, + }, + issuable: { + type: Object, + required: true, + }, + enableLabelPermalinks: { + type: Boolean, + required: true, + }, + labelFilterParam: { + type: String, + required: false, + default: 'label_name', + }, + showCheckbox: { + type: Boolean, + required: true, + }, + checked: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + issuableId() { + return getIdFromGraphQLId(this.issuable.id); + }, + createdInPastDay() { + const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date()); + return createdSecondsAgo < SECONDS_IN_DAY; + }, + author() { + return this.issuable.author; + }, + webUrl() { + return this.issuable.gitlabWebUrl || this.issuable.webUrl; + }, + authorId() { + return getIdFromGraphQLId(this.author.id); + }, + isIssuableUrlExternal() { + return isExternal(this.webUrl); + }, + reference() { + return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`; + }, + labels() { + return this.issuable.labels?.nodes || this.issuable.labels || []; + }, + labelIdsString() { + return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id))); + }, + assignees() { + return this.issuable.assignees?.nodes || this.issuable.assignees || []; + }, + createdAt() { + return getTimeago().format(this.issuable.createdAt); + }, + updatedAt() { + return sprintf(__('updated %{timeAgo}'), { + timeAgo: getTimeago().format(this.issuable.updatedAt), + }); + }, + issuableTitleProps() { + if (this.isIssuableUrlExternal) { + return { + target: '_blank', + }; + } + 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.notesCount === 'number'; + }, + showIssuableMeta() { + return Boolean( + this.hasSlotContents('status') || this.showDiscussions || this.issuable.assignees, + ); + }, + issuableNotesLink() { + return setUrlFragment(this.webUrl, 'notes'); + }, + }, + methods: { + hasSlotContents(slotName) { + return Boolean(this.$slots[slotName]); + }, + scopedLabel(label) { + return isScopedLabel(label); + }, + labelTitle(label) { + return label.title || label.name; + }, + labelTarget(label) { + if (this.enableLabelPermalinks) { + const value = encodeURIComponent(this.labelTitle(label)); + return `?${this.labelFilterParam}[]=${value}`; + } + return '#'; + }, + /** + * This is needed as an independent method since + * when user changes current page, `$refs.authorLink` + * will be null until next page results are loaded & rendered. + */ + getAuthorPopoverTarget() { + if (this.$refs.authorLink) { + return this.$refs.authorLink.$el; + } + return ''; + }, + }, +}; +</script> + +<template> + <li + :id="`issuable_${issuableId}`" + class="issue gl-display-flex! gl-px-5!" + :class="{ closed: issuable.closedAt, today: createdInPastDay }" + :data-labels="labelIdsString" + :data-qa-issue-id="issuableId" + > + <gl-form-checkbox + v-if="showCheckbox" + class="issue-check gl-mr-0" + :checked="checked" + :data-id="issuableId" + @input="$emit('checked-input', $event)" + > + <span class="gl-sr-only">{{ issuable.title }}</span> + </gl-form-checkbox> + <div class="issuable-main-info"> + <div data-testid="issuable-title" class="issue-title title"> + <gl-icon + v-if="issuable.confidential" + v-gl-tooltip + name="eye-slash" + :title="__('Confidential')" + :aria-label="__('Confidential')" + /> + <gl-icon + v-if="issuable.hidden" + v-gl-tooltip + name="spam" + :title="__('This issue is hidden because its author has been banned')" + :aria-label="__('Hidden')" + /> + <gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps"> + {{ issuable.title }} + <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> + </gl-link> + <span + v-if="taskStatus" + class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3" + data-testid="task-status" + > + {{ taskStatus }} + </span> + </div> + <div class="issuable-info"> + <slot v-if="hasSlotContents('reference')" name="reference"></slot> + <span v-else data-testid="issuable-reference" class="issuable-reference"> + {{ reference }} + </span> + <span class="gl-display-none gl-sm-display-inline"> + <span aria-hidden="true">·</span> + <span class="issuable-authored gl-mr-3"> + <gl-sprintf :message="__('created %{timeAgo} by %{author}')"> + <template #timeAgo> + <span + v-gl-tooltip.bottom + :title="tooltipTitle(issuable.createdAt)" + data-testid="issuable-created-at" + > + {{ createdAt }} + </span> + </template> + <template #author> + <slot v-if="hasSlotContents('author')" name="author"></slot> + <gl-link + v-else + :data-user-id="authorId" + :data-username="author.username" + :data-name="author.name" + :data-avatar-url="author.avatarUrl" + :href="author.webUrl" + data-testid="issuable-author" + class="author-link js-user-link" + > + <span class="author">{{ author.name }}</span> + </gl-link> + </template> + </gl-sprintf> + </span> + <slot name="timeframe"></slot> + </span> + + <span v-if="labels.length" role="group" :aria-label="__('Labels')"> + <gl-label + v-for="(label, index) in labels" + :key="index" + :background-color="label.color" + :title="labelTitle(label)" + :description="label.description" + :scoped="scopedLabel(label)" + :target="labelTarget(label)" + :class="{ 'gl-ml-2': index }" + size="sm" + /> + </span> + </div> + </div> + <div class="issuable-meta"> + <ul v-if="showIssuableMeta" class="controls"> + <li v-if="hasSlotContents('status')" class="issuable-status"> + <slot name="status"></slot> + </li> + <li v-if="assignees.length"> + <issuable-assignees + :assignees="assignees" + :icon-size="16" + :max-visible="4" + img-css-classes="gl-mr-2!" + class="gl-align-items-center gl-display-flex gl-ml-3" + /> + </li> + <slot name="statistics"></slot> + <li + v-if="showDiscussions" + data-testid="issuable-discussions" + class="issuable-comments gl-display-none gl-sm-display-block" + > + <gl-link + v-gl-tooltip.top + :title="__('Comments')" + :href="issuableNotesLink" + :class="{ 'no-comments': !notesCount }" + class="gl-reset-color!" + > + <gl-icon name="comments" /> + {{ notesCount }} + </gl-link> + </li> + </ul> + <div + v-gl-tooltip.bottom + class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block" + :title="tooltipTitle(issuable.updatedAt)" + data-testid="issuable-updated-at" + > + {{ updatedAt }} + </div> + </div> + </li> +</template> |