diff options
4 files changed, 576 insertions, 0 deletions
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue new file mode 100644 index 00000000000..27cfa8abb24 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -0,0 +1,116 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin'; + +export default { + name: 'IssueItem', + components: { + IssueMilestone, + IssueAssignees, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [relatedIssuableMixin], + props: { + canReorder: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + stateTitle() { + return sprintf( + '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>', + { + state: this.isOpen ? __('Opened') : __('Closed'), + timeInWords: this.isOpen ? this.createdAtInWords : this.closedAtInWords, + timestamp: this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp, + }, + ); + }, + }, +}; +</script> + +<template> + <div + :class="{ + 'issuable-info-container': !canReorder, + 'card-body': canReorder, + }" + class="item-body" + > + <div class="item-contents"> + <div class="item-title d-flex align-items-center"> + <icon + v-if="hasState" + v-tooltip + :css-classes="iconClass" + :name="iconName" + :size="16" + :title="stateTitle" + :aria-label="state" + data-html="true" + /> + <icon + v-if="confidential" + v-gl-tooltip + name="eye-slash" + :size="16" + :title="__('Confidential')" + class="confidential-icon append-right-4" + :aria-label="__('Confidential')" + /> + <a :href="computedPath" class="sortable-link">{{ title }}</a> + </div> + <div class="item-meta"> + <div class="d-flex align-items-center item-path-id"> + <icon + v-if="hasState" + v-tooltip + :css-classes="iconClass" + :name="iconName" + :size="16" + :title="stateTitle" + :aria-label="state" + data-html="true" + /> + <span v-tooltip :title="itemPath" class="path-id-text">{{ itemPath }}</span> + {{ pathIdSeparator }}{{ itemId }} + </div> + <div class="item-meta-child d-flex align-items-center"> + <issue-milestone + v-if="hasMilestone" + :milestone="milestone" + class="d-flex align-items-center item-milestone" + /> + <slot name="dueDate"></slot> + <slot name="weight"></slot> + </div> + <issue-assignees + v-if="assignees.length" + :assignees="assignees" + class="item-assignees d-inline-flex" + /> + </div> + </div> + <button + v-if="canRemove" + ref="removeButton" + v-tooltip + :disabled="removeDisabled" + type="button" + class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button" + title="Remove" + aria-label="Remove" + @click="onRemoveRequest" + > + <icon :size="16" class="btn-item-remove-icon" name="close" /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js new file mode 100644 index 00000000000..455ae832234 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -0,0 +1,155 @@ +import _ from 'underscore'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import tooltip from '~/vue_shared/directives/tooltip'; +import icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +const mixins = { + data() { + return { + removeDisabled: false, + }; + }, + props: { + idKey: { + type: Number, + required: true, + }, + displayReference: { + type: String, + required: true, + }, + pathIdSeparator: { + type: String, + required: true, + }, + eventNamespace: { + type: String, + required: false, + default: '', + }, + confidential: { + type: Boolean, + required: false, + default: false, + }, + title: { + type: String, + required: false, + default: '', + }, + path: { + type: String, + required: false, + default: '', + }, + state: { + type: String, + required: false, + default: '', + }, + createdAt: { + type: String, + required: false, + default: '', + }, + closedAt: { + type: String, + required: false, + default: '', + }, + milestone: { + type: Object, + required: false, + default: () => ({}), + }, + dueDate: { + type: String, + required: false, + default: '', + }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + weight: { + type: Number, + required: false, + default: 0, + }, + canRemove: { + type: Boolean, + required: false, + default: false, + }, + }, + components: { + icon, + }, + directives: { + tooltip, + }, + mixins: [timeagoMixin], + computed: { + hasState() { + return this.state && this.state.length > 0; + }, + isOpen() { + return this.state === 'opened'; + }, + isClosed() { + return this.state === 'closed'; + }, + hasTitle() { + return this.title.length > 0; + }, + hasMilestone() { + return !_.isEmpty(this.milestone); + }, + iconName() { + return this.isOpen ? 'issue-open-m' : 'issue-close'; + }, + iconClass() { + return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed'; + }, + computedLinkElementType() { + return this.path.length > 0 ? 'a' : 'span'; + }, + computedPath() { + return this.path.length ? this.path : null; + }, + itemPath() { + return this.displayReference.split(this.pathIdSeparator)[0]; + }, + itemId() { + return this.displayReference.split(this.pathIdSeparator).pop(); + }, + createdAtInWords() { + return this.createdAt ? this.timeFormated(this.createdAt) : ''; + }, + createdAtTimestamp() { + return this.createdAt ? formatDate(new Date(this.createdAt)) : ''; + }, + closedAtInWords() { + return this.closedAt ? this.timeFormated(this.closedAt) : ''; + }, + closedAtTimestamp() { + return this.closedAt ? formatDate(new Date(this.closedAt)) : ''; + }, + }, + methods: { + onRemoveRequest() { + let namespacePrefix = ''; + if (this.eventNamespace && this.eventNamespace.length > 0) { + namespacePrefix = `${this.eventNamespace}`; + } + + this.$emit(`${namespacePrefix}RemoveRequest`, this.idKey); + + this.removeDisabled = true; + }, + }, +}; + +export default mixins; diff --git a/spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js b/spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js new file mode 100644 index 00000000000..42198e92eea --- /dev/null +++ b/spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js @@ -0,0 +1,194 @@ +import Vue from 'vue'; +import { mount, createLocalVue } from '@vue/test-utils'; +import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import { defaultMilestone, defaultAssignees } from './related_issuable_mock_data'; + +describe('RelatedIssuableItem', () => { + let wrapper; + const props = { + idKey: 1, + displayReference: 'gitlab-org/gitlab-test#1', + pathIdSeparator: '#', + path: `${gl.TEST_HOST}/path`, + title: 'title', + confidential: true, + dueDate: '1990-12-31', + weight: 10, + createdAt: '2018-12-01T00:00:00.00Z', + milestone: defaultMilestone, + assignees: defaultAssignees, + eventNamespace: 'relatedIssue', + }; + const slots = { + dueDate: '<div class="js-due-date-slot"></div>', + weight: '<div class="js-weight-slot"></div>', + }; + + beforeEach(() => { + const localVue = createLocalVue(); + + wrapper = mount(localVue.extend(RelatedIssuableItem), { + localVue, + slots, + sync: false, + propsData: props, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains issuable-info-container class when canReorder is false', () => { + expect(wrapper.props('canReorder')).toBe(false); + expect(wrapper.find('.issuable-info-container').exists()).toBe(true); + }); + + it('does not render token state', () => { + expect(wrapper.find('.text-secondary svg').exists()).toBe(false); + }); + + it('does not render remove button', () => { + expect(wrapper.find({ ref: 'removeButton' }).exists()).toBe(false); + }); + + describe('token title', () => { + it('links to computedPath', () => { + expect(wrapper.find('.item-title a').attributes('href')).toEqual(wrapper.props('path')); + }); + + it('renders confidential icon', () => { + expect(wrapper.find('.confidential-icon').exists()).toBe(true); + }); + + it('renders title', () => { + expect(wrapper.find('.item-title a').text()).toEqual(props.title); + }); + }); + + describe('token state', () => { + let tokenState; + + beforeEach(done => { + wrapper.setProps({ state: 'opened' }); + + Vue.nextTick(() => { + tokenState = wrapper.find('.issue-token-state-icon-open'); + + done(); + }); + }); + + it('renders if hasState', () => { + expect(tokenState.exists()).toBe(true); + }); + + it('renders state title', () => { + const stateTitle = tokenState.attributes('data-original-title'); + + expect(stateTitle).toContain('<span class="bold">Opened</span>'); + expect(stateTitle).toContain( + '<span class="text-tertiary">Dec 1, 2018 12:00am GMT+0000</span>', + ); + }); + + it('renders aria label', () => { + expect(tokenState.attributes('aria-label')).toEqual('opened'); + }); + + it('renders open icon when open state', () => { + expect(tokenState.classes('issue-token-state-icon-open')).toBe(true); + }); + + it('renders close icon when close state', done => { + wrapper.setProps({ + state: 'closed', + closedAt: '2018-12-01T00:00:00.00Z', + }); + + Vue.nextTick(() => { + expect(tokenState.classes('issue-token-state-icon-closed')).toBe(true); + + done(); + }); + }); + }); + + describe('token metadata', () => { + let tokenMetadata; + + beforeEach(done => { + Vue.nextTick(() => { + tokenMetadata = wrapper.find('.item-meta'); + + done(); + }); + }); + + it('renders item path and ID', () => { + const pathAndID = tokenMetadata.find('.item-path-id').text(); + + expect(pathAndID).toContain('gitlab-org/gitlab-test'); + expect(pathAndID).toContain('#1'); + }); + + it('renders milestone icon and name', () => { + const milestoneIcon = tokenMetadata.find('.item-milestone svg use'); + const milestoneTitle = tokenMetadata.find('.item-milestone .milestone-title'); + + expect(milestoneIcon.attributes('href')).toContain('clock'); + expect(milestoneTitle.text()).toContain('Milestone title'); + }); + + it('renders due date component', () => { + expect(tokenMetadata.find('.js-due-date-slot').exists()).toBe(true); + }); + + it('renders weight component', () => { + expect(tokenMetadata.find('.js-weight-slot').exists()).toBe(true); + }); + }); + + describe('token assignees', () => { + it('renders assignees avatars', () => { + expect(wrapper.findAll('.item-assignees .user-avatar-link').length).toBe(2); + expect(wrapper.find('.item-assignees .avatar-counter').text()).toContain('+2'); + }); + }); + + describe('remove button', () => { + let removeBtn; + + beforeEach(done => { + wrapper.setProps({ canRemove: true }); + Vue.nextTick(() => { + removeBtn = wrapper.find({ ref: 'removeButton' }); + + done(); + }); + }); + + it('renders if canRemove', () => { + expect(removeBtn.exists()).toBe(true); + }); + + it('renders disabled button when removeDisabled', done => { + wrapper.vm.removeDisabled = true; + + Vue.nextTick(() => { + expect(removeBtn.attributes('disabled')).toEqual('disabled'); + + done(); + }); + }); + + it('triggers onRemoveRequest when clicked', () => { + removeBtn.trigger('click'); + + const { relatedIssueRemoveRequest } = wrapper.emitted(); + + expect(relatedIssueRemoveRequest.length).toBe(1); + expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js b/spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js new file mode 100644 index 00000000000..26bfdd7551e --- /dev/null +++ b/spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js @@ -0,0 +1,111 @@ +export const defaultProps = { + endpoint: '/foo/bar/issues/1/related_issues', + currentNamespacePath: 'foo', + currentProjectPath: 'bar', +}; + +export const issuable1 = { + id: 200, + epic_issue_id: 1, + confidential: false, + reference: 'foo/bar#123', + displayReference: '#123', + title: 'some title', + path: '/foo/bar/issues/123', + state: 'opened', +}; + +export const issuable2 = { + id: 201, + epic_issue_id: 2, + confidential: false, + reference: 'foo/bar#124', + displayReference: '#124', + title: 'some other thing', + path: '/foo/bar/issues/124', + state: 'opened', +}; + +export const issuable3 = { + id: 202, + epic_issue_id: 3, + confidential: false, + reference: 'foo/bar#125', + displayReference: '#125', + title: 'some other other thing', + path: '/foo/bar/issues/125', + state: 'opened', +}; + +export const issuable4 = { + id: 203, + epic_issue_id: 4, + confidential: false, + reference: 'foo/bar#126', + displayReference: '#126', + title: 'some other other other thing', + path: '/foo/bar/issues/126', + state: 'opened', +}; + +export const issuable5 = { + id: 204, + epic_issue_id: 5, + confidential: false, + reference: 'foo/bar#127', + displayReference: '#127', + title: 'some other other other thing', + path: '/foo/bar/issues/127', + state: 'opened', +}; + +export const defaultMilestone = { + id: 1, + state: 'active', + title: 'Milestone title', + start_date: '2018-01-01', + due_date: '2019-12-31', +}; + +export const defaultAssignees = [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: `${gl.TEST_HOST}`, + web_url: `${gl.TEST_HOST}/root`, + status_tooltip_html: null, + path: '/root', + }, + { + id: 13, + name: 'Brooks Beatty', + username: 'brynn_champlin', + state: 'active', + avatar_url: `${gl.TEST_HOST}`, + web_url: `${gl.TEST_HOST}/brynn_champlin`, + status_tooltip_html: null, + path: '/brynn_champlin', + }, + { + id: 6, + name: 'Bryce Turcotte', + username: 'melynda', + state: 'active', + avatar_url: `${gl.TEST_HOST}`, + web_url: `${gl.TEST_HOST}/melynda`, + status_tooltip_html: null, + path: '/melynda', + }, + { + id: 20, + name: 'Conchita Eichmann', + username: 'juliana_gulgowski', + state: 'active', + avatar_url: `${gl.TEST_HOST}`, + web_url: `${gl.TEST_HOST}/juliana_gulgowski`, + status_tooltip_html: null, + path: '/juliana_gulgowski', + }, +]; |