Commit b23c3fd2 authored by Jacob Schatz's avatar Jacob Schatz

Merge branch 'epic-in-issue' into 'master'

Add epic information to issue sidebar

Closes #3696

See merge request gitlab-org/gitlab-ee!3579
parents 02bd4f66 3539abf3
import Flash from '../../../flash';
import AssigneeTitle from './assignee_title';
import Assignees from './assignees';
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
export default {
name: 'SidebarAssignees',
data() {
return {
mediator: new Mediator(),
store: new Store(),
loading: false,
field: '',
};
},
props: {
mediator: {
type: Object,
required: true,
},
field: {
type: String,
required: true,
},
signedIn: {
type: Boolean,
required: false,
default: false,
},
},
components: {
'assignee-title': AssigneeTitle,
assignees: Assignees,
......@@ -61,10 +71,6 @@ export default {
eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
},
beforeMount() {
this.field = this.$el.dataset.field;
this.signedIn = typeof this.$el.dataset.signedIn !== 'undefined';
},
template: `
<div>
<assignee-title
......
<script>
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
import participants from './participants.vue';
export default {
data() {
return {
mediator: new Mediator(),
store: new Store(),
};
},
props: {
mediator: {
type: Object,
required: true,
},
},
components: {
participants,
},
......@@ -21,6 +25,7 @@ export default {
<participants
:loading="store.isFetching.participants"
:participants="store.participants"
:number-of-less-participants="7" />
:number-of-less-participants="7"
/>
</div>
</template>
<script>
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
import Flash from '../../../flash';
import { __ } from '../../../locale';
......@@ -9,11 +8,15 @@ import subscriptions from './subscriptions.vue';
export default {
data() {
return {
mediator: new Mediator(),
store: new Store(),
};
},
props: {
mediator: {
type: Object,
required: true,
},
},
components: {
subscriptions,
},
......
......@@ -10,6 +10,27 @@ import Translate from '../vue_shared/translate';
Vue.use(Translate);
function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
SidebarAssignees,
},
render: createElement => createElement('sidebar-assignees', {
props: {
mediator,
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
},
}),
});
}
function mountConfidentialComponent(mediator) {
const el = document.getElementById('js-confidential-entry-point');
......@@ -49,9 +70,10 @@ function mountLockComponent(mediator) {
}).$mount(el);
}
function mountParticipantsComponent() {
function mountParticipantsComponent(mediator) {
const el = document.querySelector('.js-sidebar-participants-entry-point');
// eslint-disable-next-line no-new
if (!el) return;
// eslint-disable-next-line no-new
......@@ -60,11 +82,15 @@ function mountParticipantsComponent() {
components: {
sidebarParticipants,
},
render: createElement => createElement('sidebar-participants', {}),
render: createElement => createElement('sidebar-participants', {
props: {
mediator,
},
}),
});
}
function mountSubscriptionsComponent() {
function mountSubscriptionsComponent(mediator) {
const el = document.querySelector('.js-sidebar-subscriptions-entry-point');
if (!el) return;
......@@ -75,22 +101,35 @@ function mountSubscriptionsComponent() {
components: {
sidebarSubscriptions,
},
render: createElement => createElement('sidebar-subscriptions', {}),
render: createElement => createElement('sidebar-subscriptions', {
props: {
mediator,
},
}),
});
}
function mount(mediator) {
const sidebarAssigneesEl = document.getElementById('js-vue-sidebar-assignees');
// Only create the sidebarAssignees vue app if it is found in the DOM
// We currently do not use sidebarAssignees for the MR page
if (sidebarAssigneesEl) {
new Vue(SidebarAssignees).$mount(sidebarAssigneesEl);
}
function mountTimeTrackingComponent() {
const el = document.getElementById('issuable-time-tracker');
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
SidebarTimeTracking,
},
render: createElement => createElement('sidebar-time-tracking', {}),
});
}
export function mountSidebar(mediator) {
mountAssigneesComponent(mediator);
mountConfidentialComponent(mediator);
mountLockComponent(mediator);
mountParticipantsComponent();
mountSubscriptionsComponent();
mountParticipantsComponent(mediator);
mountSubscriptionsComponent(mediator);
new SidebarMoveIssue(
mediator,
......@@ -98,7 +137,9 @@ function mount(mediator) {
$('.js-move-issue-confirmation-button'),
).init();
new Vue(SidebarTimeTracking).$mount('#issuable-time-tracker');
mountTimeTrackingComponent();
}
export default mount;
export function getSidebarOptions() {
return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
}
import mountSidebarEE from 'ee/sidebar/mount_sidebar';
import Mediator from 'ee/sidebar/sidebar_mediator';
import mountSidebar from './mount_sidebar';
import Mediator from './sidebar_mediator';
import { mountSidebar, getSidebarOptions } from './mount_sidebar';
function domContentLoaded() {
const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
const mediator = new Mediator(sidebarOptions);
const mediator = new Mediator(getSidebarOptions());
mediator.fetch();
mountSidebar(mediator);
mountSidebarEE(mediator);
}
document.addEventListener('DOMContentLoaded', domContentLoaded);
......
......@@ -7,7 +7,6 @@ export default class SidebarMediator {
if (!SidebarMediator.singleton) {
this.initSingleton(options);
}
return SidebarMediator.singleton;
}
......
export default class SidebarStore {
constructor(store) {
constructor(options) {
if (!SidebarStore.singleton) {
const { currentUser, rootPath, editable } = store;
this.currentUser = currentUser;
this.rootPath = rootPath;
this.editable = editable;
this.timeEstimate = 0;
this.totalTimeSpent = 0;
this.humanTimeEstimate = '';
this.humanTimeSpent = '';
this.assignees = [];
this.isFetching = {
assignees: true,
participants: true,
subscriptions: true,
};
this.isLoading = {};
this.autocompleteProjects = [];
this.moveToProjectId = 0;
this.isLockDialogOpen = false;
this.participants = [];
this.subscribed = null;
SidebarStore.singleton = this;
this.initSingleton(options);
}
return SidebarStore.singleton;
}
initSingleton(options) {
const { currentUser, rootPath, editable } = options;
this.currentUser = currentUser;
this.rootPath = rootPath;
this.editable = editable;
this.timeEstimate = 0;
this.totalTimeSpent = 0;
this.humanTimeEstimate = '';
this.humanTimeSpent = '';
this.assignees = [];
this.isFetching = {
assignees: true,
participants: true,
subscriptions: true,
};
this.isLoading = {};
this.autocompleteProjects = [];
this.moveToProjectId = 0;
this.isLockDialogOpen = false;
this.participants = [];
this.subscribed = null;
SidebarStore.singleton = this;
}
setAssigneeData(data) {
this.isFetching.assignees = false;
if (data.assignees) {
......
......@@ -50,6 +50,11 @@
&:not(.disabled) {
cursor: pointer;
}
svg {
width: $gl-padding;
height: $gl-padding;
}
}
}
......
......@@ -471,7 +471,8 @@
}
}
.milestone-title span {
.milestone-title span,
.collapse-truncated-title {
@include str-truncated(100%);
display: block;
margin: 0 4px;
......
class IssueSidebarEntity < IssuableSidebarEntity
prepend ::EE::IssueSidebarEntity
expose :assignees, using: API::Entities::UserBasic
end
- todo = issuable_todo(issuable)
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('sidebar')
= page_specific_javascript_bundle_tag('ee_sidebar')
%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
......@@ -21,6 +21,7 @@
= render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true
.block.assignee
= render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present?
= render "shared/issuable/sidebar_item_epic", issuable: issuable
.block.milestone
.sidebar-collapsed-icon
= icon('clock-o', 'aria-hidden': 'true')
......
---
title: Add epic information to issue sidebar
merge_request:
author:
type: added
......@@ -84,6 +84,7 @@ var config = {
registry_list: './registry/index.js',
repo: './repo/index.js',
sidebar: './sidebar/sidebar_bundle.js',
ee_sidebar: 'ee/sidebar/sidebar_bundle.js',
schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js',
schedules_index: './pipeline_schedules/pipeline_schedules_index_bundle.js',
snippet: './snippet/snippet_bundle.js',
......
<script>
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import { spriteIcon } from '~/lib/utils/common_utils';
import Store from '../stores/sidebar_store';
export default {
name: 'sidebarItemEpic',
data() {
return {
store: new Store(),
};
},
components: {
LoadingIcon,
},
directives: {
tooltip,
},
computed: {
isLoading() {
return this.store.isFetching.epic;
},
epicIcon() {
return spriteIcon('epic');
},
epicUrl() {
return this.store.epic.url;
},
epicTitle() {
return this.store.epic.title;
},
hasEpic() {
return this.epicUrl && this.epicTitle;
},
collapsedTitle() {
return this.hasEpic ? this.epicTitle : 'None';
},
},
};
</script>
<template>
<div>
<div class="sidebar-collapsed-icon">
<div v-html="epicIcon"></div>
<span
v-if="!isLoading"
class="collapse-truncated-title"
:title="epicTitle"
data-container="body"
data-placement="left"
v-tooltip
>
{{collapsedTitle}}
</span>
</div>
<div class="title hide-collapsed">
Epic
<loading-icon
v-if="isLoading"
:inline="true"
/>
</div>
<div
v-if="!isLoading"
class="value hide-collapsed"
>
<a
v-if="hasEpic"
class="bold"
:href="epicUrl"
>
{{epicTitle}}
</a>
<span
v-else
class="no-value"
>
None
</span>
</div>
</div>
</template>
import Vue from 'vue';
import * as CEMountSidebar from '~/sidebar/mount_sidebar';
import sidebarWeight from './components/weight/sidebar_weight.vue';
import SidebarItemEpic from './components/sidebar_item_epic.vue';
function mountWeightComponent(mediator) {
const el = document.querySelector('.js-sidebar-weight-entry-point');
......@@ -20,8 +22,20 @@ function mountWeightComponent(mediator) {
});
}
function mount(mediator) {
mountWeightComponent(mediator);
function mountEpic() {
const el = document.querySelector('#js-vue-sidebar-item-epic');
return new Vue({
el,
components: {
SidebarItemEpic,
},
render: createElement => createElement('sidebar-item-epic', {}),
});
}
export default mount;
export default function mountSidebar(mediator) {
CEMountSidebar.mountSidebar(mediator);
mountWeightComponent(mediator);
mountEpic();
}
import { getSidebarOptions } from '~/sidebar/mount_sidebar';
import Mediator from './sidebar_mediator';
import mountSidebar from './mount_sidebar';
function domContentLoaded() {
const mediator = new Mediator(getSidebarOptions());
mediator.fetch();
mountSidebar(mediator);
}
document.addEventListener('DOMContentLoaded', domContentLoaded);
export default domContentLoaded;
......@@ -10,6 +10,7 @@ export default class SidebarMediator extends CESidebarMediator {
processFetchedData(data) {
super.processFetchedData(data);
this.store.setWeightData(data);
this.store.setEpicData(data);
}
updateWeight(newWeight) {
......
import CESidebarStore from '~/sidebar/stores/sidebar_store';
export default class SidebarStore extends CESidebarStore {
constructor(store) {
super(store);
initSingleton(options) {
super.initSingleton(options);
this.isFetching.weight = true;
this.isFetching.epic = true;
this.isLoading.weight = false;
this.weight = null;
this.weightOptions = store.weightOptions;
this.weightNoneValue = store.weightNoneValue;
this.weightOptions = options.weightOptions;
this.weightNoneValue = options.weightNoneValue;
this.epic = {};
}
setWeightData(data) {
......@@ -19,4 +21,9 @@ export default class SidebarStore extends CESidebarStore {
setWeight(newWeight) {
this.weight = newWeight;
}
setEpicData(data) {
this.isFetching.epic = false;
this.epic = data.epic || {};
}
}
module EE
module IssueSidebarEntity
extend ActiveSupport::Concern
prepended do
expose :epic, using: EpicBaseEntity
end
end
end
class EpicBaseEntity < Grape::Entity
include RequestAwareEntity
expose :id
expose :title
expose :url do |epic|
group_epic_path(epic.group, epic)
end
end
- return unless issuable.project.group&.feature_available?(:epics)
- if issuable.is_a?(Issue)
.block.epic
#js-vue-sidebar-item-epic
.title.hide-collapsed
Epic
= icon('spinner spin')
require 'spec_helper'
describe 'Epic in issue sidebar', :js do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
let(:epic) { create(:epic, group: group) }
let(:project) { create(:project, :public, group: group) }
let(:issue) { create(:issue, project: project) }
let!(:epic_issue) { create(:epic_issue, epic: epic, issue: issue) }
context 'when epics available' do
before do
stub_licensed_features(epics: true)
sign_in(user)
visit project_issue_path(project, issue)
end
it 'shows epic in issue sidebar' do
expect(page.find('.block.epic .value')).to have_content(epic.title)
end
end
context 'when epics unavailable' do
before do
stub_licensed_features(epics: false)
sign_in(user)
visit project_issue_path(project, issue)
end
it 'does not show epic in issue sidebar' do
expect(page).not_to have_selector('.block.epic')
end
end
end
{
"type": "object",
"properties" : {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"subscribed": { "type": "boolean" },
"time_estimate": { "type": "integer" },
"total_time_spent": { "type": "integer" },
......@@ -16,6 +14,14 @@
"type": "array",
"items": { "$ref": "../public_api/v4/user/basic.json" }
},
"epic": {
"type": ["object", "null"],
"properties": {
"id": { "type": "integer" },
"title": { "type": "string" },
"url": { "type": "string" }
}
},
"weight": { "type": ["integer", "null"] }
},
"additionalProperties": false
......
import Vue from 'vue';
import CESidebarStore from '~/sidebar/stores/sidebar_store';
import SidebarStore from 'ee/sidebar/stores/sidebar_store';
import sidebarItemEpic from 'ee/sidebar/components/sidebar_item_epic.vue';
import mountComponent from '../helpers/vue_mount_component_helper';
describe('sidebarItemEpic', () => {
let vm;
let sidebarStore;
beforeEach(() => {
sidebarStore = new SidebarStore({
currentUser: '',
rootPath: '',
editable: false,
});
const SidebarItemEpic = Vue.extend(sidebarItemEpic);
vm = mountComponent(SidebarItemEpic, {});
});
afterEach(() => {
vm.$destroy();
CESidebarStore.singleton = null;
});
describe('loading', () => {
it('shows loading icon', () => {
expect(vm.$el.querySelector('.fa-spin')).toBeDefined();
});
it('hides collapsed title', () => {
expect(vm.$el.querySelector('.sidebar-collapsed-icon .collapsed-truncated-title')).toBeNull();
});
});
describe('loaded', () => {
const epicTitle = 'epic title';
const url = 'https://gitlab.com/';
beforeEach((done) => {
sidebarStore.setEpicData({
epic: {
title: epicTitle,
id: 1,
url,
},
});
Vue.nextTick(done);
});
it('shows epic title', () => {
expect(vm.$el.querySelector('.value').innerText.trim()).toEqual(epicTitle);
});
it('links epic title to epic url', () => {
expect(vm.$el.querySelector('a').href).toEqual(url);
});
it('shows epic title as collapsed title tooltip', () => {
expect(vm.$el.querySelector('.collapse-truncated-title').getAttribute('title')).toBeDefined();
expect(vm.$el.querySelector('.collapse-truncated-title').getAttribute('data-original-title')).toEqual(epicTitle);
});
describe('no epic', () => {
beforeEach((done) => {
sidebarStore.epic = {};
Vue.nextTick(done);
});
it('shows none as the epic text', () => {
expect(vm.$el.querySelector('.value').innerText.trim()).toEqual('None');
});
it('shows none as the collapsed title', () => {
expect(vm.$el.querySelector('.collapse-truncated-title').innerText.trim()).toEqual('None');
});
it('hides collapsed title tooltip', () => {
expect(vm.$el.querySelector('.collapse-truncated-title').getAttribute('title')).toBeNull();
});
});
});
});
import Vue from 'vue';
import SidebarMediator from 'ee/sidebar/sidebar_mediator';
import SidebarStore from 'ee/sidebar/stores/sidebar_store';
import CESidebarMediator from '~/sidebar/sidebar_mediator';
import CESidebarStore from '~/sidebar/stores/sidebar_store';
import SidebarService from '~/sidebar/services/sidebar_service';
import Mock from './ee_mock_data';
......@@ -12,8 +13,8 @@ describe('EE Sidebar mediator', () => {
afterEach(() => {
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
CESidebarStore.singleton = null;
CESidebarMediator.singleton = null;
Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor);
});
......
import SidebarStore from 'ee/sidebar/stores/sidebar_store';
import CESidebarStore from '~/sidebar/stores/sidebar_store';
describe('EE Sidebar store', () => {
let store;
beforeEach(() => {
this.store = new SidebarStore({
store = new SidebarStore({
weight: null,
weightOptions: ['No Weight', 0, 1, 3],
weightNoneValue: 'No Weight',
});
});
afterEach(() => {
SidebarStore.singleton = null;
// Since CESidebarStore stores the actual singleton instance
// we need to clear that specific reference
CESidebarStore.singleton = null;
});
it('sets weight data', () => {
expect(this.store.weight).toEqual(null);
expect(store.weight).toEqual(null);
const weight = 3;
this.store.setWeightData({
store.setWeightData({
weight,
});
expect(this.store.isFetching.weight).toEqual(false);
expect(this.store.weight).toEqual(weight);
expect(store.isFetching.weight).toEqual(false);
expect(store.weight).toEqual(weight);
});
it('set weight', () => {
const weight = 3;
this.store.setWeight(weight);
expect(store.weight).toEqual(null);
const weight = 1;
store.setWeight(weight);
expect(this.store.weight).toEqual(weight);
expect(store.weight).toEqual(weight);
});
});
......@@ -4,20 +4,29 @@ import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import Mock from './mock_data';
import mountComponent from '../helpers/vue_mount_component_helper';
describe('sidebar assignees', () => {
let component;
let SidebarAssigneeComponent;
let vm;
let mediator;
let sidebarAssigneesEl;
preloadFixtures('issues/open-issue.html.raw');
beforeEach(() => {
Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
SidebarAssigneeComponent = Vue.extend(SidebarAssignees);
spyOn(SidebarMediator.prototype, 'saveAssignees').and.callThrough();
spyOn(SidebarMediator.prototype, 'assignYourself').and.callThrough();
this.mediator = new SidebarMediator(Mock.mediator);
loadFixtures('issues/open-issue.html.raw');
this.sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
mediator = new SidebarMediator(Mock.mediator);
spyOn(mediator, 'saveAssignees').and.callThrough();
spyOn(mediator, 'assignYourself').and.callThrough();
const SidebarAssigneeComponent = Vue.extend(SidebarAssignees);
sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
vm = mountComponent(SidebarAssigneeComponent, {
mediator,
field: sidebarAssigneesEl.dataset.field,
}, sidebarAssigneesEl);
});
afterEach(() => {
......@@ -28,30 +37,24 @@ describe('sidebar assignees', () => {
});
it('calls the mediator when saves the assignees', () => {
component = new SidebarAssigneeComponent()
.$mount(this.sidebarAssigneesEl);
component.saveAssignees();
expect(SidebarMediator.prototype.saveAssignees).toHaveBeenCalled();
vm.saveAssignees();
expect(mediator.saveAssignees).toHaveBeenCalled();
});
it('calls the mediator when "assignSelf" method is called', () => {
component = new SidebarAssigneeComponent()
.$mount(this.sidebarAssigneesEl);
component.assignSelf();
vm.assignSelf();
expect(SidebarMediator.prototype.assignYourself).toHaveBeenCalled();
expect(this.mediator.store.assignees.length).toEqual(1);
expect(mediator.assignYourself).toHaveBeenCalled();
expect(mediator.store.assignees.length).toEqual(1);
});
it('hides assignees until fetched', (done) => {
component = new SidebarAssigneeComponent().$mount(this.sidebarAssigneesEl);
const currentAssignee = this.sidebarAssigneesEl.querySelector('.value');
const currentAssignee = sidebarAssigneesEl.querySelector('.value');
expect(currentAssignee).toBe(null);
component.store.isFetching.assignees = false;
vm.store.isFetching.assignees = false;
Vue.nextTick(() => {
expect(component.$el.querySelector('.value')).toBeVisible();
expect(vm.$el.querySelector('.value')).toBeVisible();
done();
});
});
......
......@@ -26,11 +26,14 @@ describe('Sidebar Subscriptions', function () {
});
it('calls the mediator toggleSubscription on event', () => {
spyOn(SidebarMediator.prototype, 'toggleSubscription').and.returnValue(Promise.resolve());
vm = mountComponent(SidebarSubscriptions, {});
const mediator = new SidebarMediator();
spyOn(mediator, 'toggleSubscription').and.returnValue(Promise.resolve());
vm = mountComponent(SidebarSubscriptions, {
mediator,
});
eventHub.$emit('toggleSubscription');
expect(SidebarMediator.prototype.toggleSubscription).toHaveBeenCalled();
expect(mediator.toggleSubscription).toHaveBeenCalled();
});
});
......@@ -20,8 +20,12 @@ describe IssueSerializer do
context 'sidebar issue serialization' do
let(:serializer) { 'sidebar' }
before do
create(:epic_issue, issue: resource)
end
it 'matches sidebar issue json schema' do
expect(json_entity).to match_schema('entities/issue_sidebar')
expect(json_entity).to match_schema('entities/issue_sidebar', strict: true)
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment