From 529c570c0271a94f7356815f8cb63dca9ed3f716 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Wed, 27 Feb 2019 20:12:40 +0100 Subject: Move related issues shared components from EE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We will rewrite Related MRs widget in CE with Vue. It’s pretty much the same with Related Issues in EE. I made EE only components reusable and this is the CE backward compatability commit. Links: Issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/57662 MR: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/9730 --- .../components/issue/related_issuable_item.vue | 116 ++++++++++++ .../vue_shared/mixins/related_issuable_mixin.js | 155 ++++++++++++++++ .../components/issue/related_issuable_item_spec.js | 194 +++++++++++++++++++++ .../components/issue/related_issuable_mock_data.js | 111 ++++++++++++ 4 files changed, 576 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue create mode 100644 app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js create mode 100644 spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js create mode 100644 spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js 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 @@ + + + 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: '
', + weight: '
', + }; + + 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('Opened'); + expect(stateTitle).toContain( + 'Dec 1, 2018 12:00am GMT+0000', + ); + }); + + 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', + }, +]; -- cgit v1.2.1