Move related issues shared components from EE
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
This commit is contained in:
parent
728e80798b
commit
529c570c02
|
@ -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>
|
|
@ -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;
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
},
|
||||
];
|
Loading…
Reference in New Issue