Commit 251cb571 authored by Douwe Maan's avatar Douwe Maan

Merge branch '8845-epic-sidebar-list' into 'master'

Expose a list of epic ancestors for issues and epics

Closes #8845

See merge request gitlab-org/gitlab-ee!9817
parents 8fa83aed 81ba3277
...@@ -157,3 +157,55 @@ ...@@ -157,3 +157,55 @@
.sidebar-collapsed-icon .sidebar-collapsed-value { .sidebar-collapsed-icon .sidebar-collapsed-value {
font-size: 12px; font-size: 12px;
} }
.ancestor-tree {
.vertical-timeline {
position: relative;
list-style: none;
margin: 0;
padding: 0;
&::before {
content: '';
border-left: 1px solid $gray-500;
position: absolute;
top: $gl-padding;
bottom: $gl-padding;
left: map-get($spacers, 2) - 1px;
}
&-row {
margin-top: map-get($spacers, 3);
&:nth-child(1) {
margin-top: 0;
}
}
&-icon {
/**
* 2px extra is to give a little more height than needed
* to hide timeline line before/after the element starts/ends
*/
height: map-get($spacers, 4) + 2px;
z-index: 1;
position: relative;
top: -3px;
padding: $gl-padding-4 0;
background-color: $gray-light;
&.opened {
color: $green-500;
}
&.closed {
color: $blue-500;
}
}
&-content {
line-height: initial;
margin-left: $gl-padding-8;
}
}
}
<script> <script>
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import SidebarParentEpic from 'ee/sidebar/components/sidebar_item_epic.vue'; import AncestorsTree from 'ee/sidebar/components/ancestors_tree/ancestors_tree.vue';
import epicUtils from '../utils/epic_utils'; import epicUtils from '../utils/epic_utils';
...@@ -23,7 +23,7 @@ export default { ...@@ -23,7 +23,7 @@ export default {
SidebarDatePicker, SidebarDatePicker,
SidebarDatePickerCollapsed, SidebarDatePickerCollapsed,
SidebarLabels, SidebarLabels,
SidebarParentEpic, AncestorsTree,
SidebarParticipants, SidebarParticipants,
SidebarSubscription, SidebarSubscription,
}, },
...@@ -56,7 +56,7 @@ export default { ...@@ -56,7 +56,7 @@ export default {
'dueDateTimeFromMilestones', 'dueDateTimeFromMilestones',
'dueDateTime', 'dueDateTime',
'dueDateForCollapsedSidebar', 'dueDateForCollapsedSidebar',
'parentEpic', 'ancestors',
]), ]),
}, },
mounted() { mounted() {
...@@ -184,8 +184,8 @@ export default { ...@@ -184,8 +184,8 @@ export default {
@toggleCollapse="toggleSidebar({ sidebarCollapsed })" @toggleCollapse="toggleSidebar({ sidebarCollapsed })"
/> />
<sidebar-labels :can-update="canUpdate" :sidebar-collapsed="sidebarCollapsed" /> <sidebar-labels :can-update="canUpdate" :sidebar-collapsed="sidebarCollapsed" />
<div class="block parent-epic"> <div class="block ancestors">
<sidebar-parent-epic :block-title="__('Parent epic')" :initial-epic="parentEpic" /> <ancestors-tree :ancestors="ancestors" :is-fetching="false" />
</div> </div>
<div class="block participants"> <div class="block participants">
<sidebar-participants <sidebar-participants
......
...@@ -55,7 +55,7 @@ export const isDateInvalid = (state, getters) => { ...@@ -55,7 +55,7 @@ export const isDateInvalid = (state, getters) => {
); );
}; };
export const parentEpic = state => (state.parent ? state.parent : {}); export const ancestors = state => (state.ancestors ? [...state.ancestors].reverse() : []);
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -55,7 +55,7 @@ export default () => ({ ...@@ -55,7 +55,7 @@ export default () => ({
dueDateFromMilestones: '', dueDateFromMilestones: '',
dueDate: '', dueDate: '',
labels: [], labels: [],
parent: null, ancestors: [],
participants: [], participants: [],
subscribed: false, subscribed: false,
......
<script>
import { GlLoadingIcon, GlLink, GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'AncestorsTree',
components: {
Icon,
GlLoadingIcon,
GlLink,
GlTooltip,
},
props: {
ancestors: {
type: Array,
required: true,
default: () => [],
},
isFetching: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
tooltipText() {
/**
* Since the list is reveresed, our immediate parent is
* the last element of the list
*/
const immediateParent = this.ancestors.slice(-1)[0];
if (!immediateParent) {
return __('None');
}
// Fallback to None if immediate parent is unavailable.
let { title } = immediateParent;
const { humanReadableEndDate, humanReadableTimestamp } = immediateParent;
if (humanReadableEndDate || humanReadableTimestamp) {
title += '<br />';
title += humanReadableEndDate ? `${humanReadableEndDate} ` : '';
title += humanReadableTimestamp ? `(${humanReadableTimestamp})` : '';
}
return title;
},
},
methods: {
getIcon(ancestor) {
return ancestor.state === 'opened' ? 'issue-open-m' : 'issue-close';
},
getTimelineClass(ancestor) {
return ancestor.state === 'opened' ? 'opened' : 'closed';
},
},
};
</script>
<template>
<div class="ancestor-tree">
<div ref="sidebarIcon" class="sidebar-collapsed-icon">
<div><icon name="epic" /></div>
<span v-if="!isFetching" class="collapse-truncated-title">{{ tooltipText }}</span>
</div>
<gl-tooltip :target="() => $refs.sidebarIcon" placement="left" boundary="viewport">
<span v-html="tooltipText"></span>
</gl-tooltip>
<div class="title hide-collapsed">{{ __('Ancestors') }}</div>
<ul v-if="!isFetching && ancestors.length" class="vertical-timeline hide-collapsed">
<li v-for="(ancestor, id) in ancestors" :key="id" class="vertical-timeline-row d-flex">
<div class="vertical-timeline-icon" :class="getTimelineClass(ancestor)">
<icon :name="getIcon(ancestor)" />
</div>
<div class="vertical-timeline-content">
<gl-link :href="ancestor.url" target="_blank">{{ ancestor.title }}</gl-link>
</div>
</li>
</ul>
<div v-if="!isFetching && !ancestors.length" class="value hide-collapsed">
<span class="no-value">{{ __('None') }}</span>
</div>
<gl-loading-icon v-if="isFetching" />
</div>
</template>
# frozen_string_literal: true # frozen_string_literal: true
module EpicsHelper module EpicsHelper
include EntityDateHelper
# rubocop: disable Metrics/AbcSize # rubocop: disable Metrics/AbcSize
def epic_show_app_data(epic, opts) def epic_show_app_data(epic, opts)
group = epic.group group = epic.group
...@@ -10,7 +12,7 @@ module EpicsHelper ...@@ -10,7 +12,7 @@ module EpicsHelper
epic_id: epic.id, epic_id: epic.id,
created: epic.created_at, created: epic.created_at,
author: epic_author(epic, opts), author: epic_author(epic, opts),
parent: epic_parent(epic.parent), ancestors: epic_ancestors(epic.ancestors.inc_group),
todo_exists: todo.present?, todo_exists: todo.present?,
todo_path: group_todos_path(group), todo_path: group_todos_path(group),
start_date: epic.start_date, start_date: epic.start_date,
...@@ -75,14 +77,17 @@ module EpicsHelper ...@@ -75,14 +77,17 @@ module EpicsHelper
} }
end end
def epic_parent(epic) def epic_ancestors(epics)
return unless epic epics.map do |epic|
{
{ id: epic.id,
id: epic.id, title: epic.title,
title: epic.title, url: epic_path(epic),
url: epic_path(epic) state: epic.state,
} human_readable_end_date: epic.end_date&.to_s(:medium),
human_readable_timestamp: remaining_days_in_words(epic.end_date, epic.start_date)
}
end
end end
def epic_endpoint_query_params(opts) def epic_endpoint_query_params(opts)
......
...@@ -48,6 +48,7 @@ module EE ...@@ -48,6 +48,7 @@ module EE
alias_attribute :parent_ids, :parent_id alias_attribute :parent_ids, :parent_id
scope :in_parents, -> (parent_ids) { where(parent_id: parent_ids) } scope :in_parents, -> (parent_ids) { where(parent_id: parent_ids) }
scope :inc_group, -> { includes(:group) }
scope :order_start_or_end_date_asc, -> do scope :order_start_or_end_date_asc, -> do
# mysql returns null values first in opposite to postgres which # mysql returns null values first in opposite to postgres which
...@@ -280,7 +281,7 @@ module EE ...@@ -280,7 +281,7 @@ module EE
def ancestors def ancestors
return self.class.none unless parent_id return self.class.none unless parent_id
hierarchy.ancestors hierarchy.ancestors(hierarchy_order: :asc)
end end
def descendants def descendants
......
---
title: Add Ancestors in Epic Sidebar
merge_request: 9817
author:
type: added
...@@ -8,7 +8,7 @@ describe EpicsHelper do ...@@ -8,7 +8,7 @@ describe EpicsHelper do
let(:group) { create(:group) } let(:group) { create(:group) }
let(:milestone1) { create(:milestone, title: 'make me a sandwich', start_date: '2010-01-01', due_date: '2019-12-31') } let(:milestone1) { create(:milestone, title: 'make me a sandwich', start_date: '2010-01-01', due_date: '2019-12-31') }
let(:milestone2) { create(:milestone, title: 'make me a pizza', start_date: '2020-01-01', due_date: '2029-12-31') } let(:milestone2) { create(:milestone, title: 'make me a pizza', start_date: '2020-01-01', due_date: '2029-12-31') }
let(:parent_epic) { create(:epic, group: group) } let(:parent_epic) { create(:epic, group: group, start_date: Date.new(2000, 1, 10), due_date: Date.new(2000, 1, 20)) }
let!(:epic) do let!(:epic) do
create( create(
:epic, :epic,
...@@ -34,7 +34,7 @@ describe EpicsHelper do ...@@ -34,7 +34,7 @@ describe EpicsHelper do
expected_keys = %i(initial meta namespace labels_path toggle_subscription_path labels_web_url epics_web_url) expected_keys = %i(initial meta namespace labels_path toggle_subscription_path labels_web_url epics_web_url)
expect(data.keys).to match_array(expected_keys) expect(data.keys).to match_array(expected_keys)
expect(meta_data.keys).to match_array(%w[ expect(meta_data.keys).to match_array(%w[
epic_id created author todo_exists todo_path start_date parent epic_id created author todo_exists todo_path start_date ancestors
start_date_is_fixed start_date_fixed start_date_from_milestones start_date_is_fixed start_date_fixed start_date_from_milestones
start_date_sourcing_milestone_title start_date_sourcing_milestone_dates start_date_sourcing_milestone_title start_date_sourcing_milestone_dates
due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title
...@@ -47,11 +47,6 @@ describe EpicsHelper do ...@@ -47,11 +47,6 @@ describe EpicsHelper do
'username' => "@#{user.username}", 'username' => "@#{user.username}",
'src' => 'icon_path' 'src' => 'icon_path'
}) })
expect(meta_data['parent']).to eq({
'id' => parent_epic.id,
'title' => parent_epic.title,
'url' => "/groups/#{group.full_path}/-/epics/#{parent_epic.iid}"
})
expect(meta_data['start_date']).to eq('2000-01-01') expect(meta_data['start_date']).to eq('2000-01-01')
expect(meta_data['start_date_sourcing_milestone_title']).to eq(milestone1.title) expect(meta_data['start_date_sourcing_milestone_title']).to eq(milestone1.title)
expect(meta_data['start_date_sourcing_milestone_dates']['start_date']).to eq(milestone1.start_date.to_s) expect(meta_data['start_date_sourcing_milestone_dates']['start_date']).to eq(milestone1.start_date.to_s)
...@@ -62,6 +57,32 @@ describe EpicsHelper do ...@@ -62,6 +57,32 @@ describe EpicsHelper do
expect(meta_data['due_date_sourcing_milestone_dates']['due_date']).to eq(milestone2.due_date.to_s) expect(meta_data['due_date_sourcing_milestone_dates']['due_date']).to eq(milestone2.due_date.to_s)
end end
it 'returns a list of epic ancestors', :nested_groups do
data = helper.epic_show_app_data(epic, initial: {}, author_icon: 'icon_path')
meta_data = JSON.parse(data[:meta])
expect(meta_data['ancestors']).to eq([{
'id' => parent_epic.id,
'title' => parent_epic.title,
'url' => "/groups/#{group.full_path}/-/epics/#{parent_epic.iid}",
'state' => 'opened',
'human_readable_end_date' => 'Jan 20, 2000',
'human_readable_timestamp' => '<strong>Past due</strong>'
}])
end
it 'avoids N+1 database queries', :nested_groups do
group1 = create(:group)
group2 = create(:group, parent: group1)
epic1 = create(:epic, group: group1)
epic2 = create(:epic, group: group1, parent: epic1)
epic3 = create(:epic, group: group2, parent: epic2)
control_count = ActiveRecord::QueryRecorder.new { helper.epic_show_app_data(epic2, initial: {}) }
expect { helper.epic_show_app_data(epic3, initial: {}) }.not_to exceed_query_limit(control_count)
end
context 'when a user can update an epic' do context 'when a user can update an epic' do
let(:milestone) { create(:milestone, title: 'make me a sandwich') } let(:milestone) { create(:milestone, title: 'make me a sandwich') }
...@@ -85,7 +106,7 @@ describe EpicsHelper do ...@@ -85,7 +106,7 @@ describe EpicsHelper do
meta_data = JSON.parse(data[:meta]) meta_data = JSON.parse(data[:meta])
expect(meta_data.keys).to match_array(%w[ expect(meta_data.keys).to match_array(%w[
epic_id created author todo_exists todo_path start_date parent epic_id created author todo_exists todo_path start_date ancestors
start_date_is_fixed start_date_fixed start_date_from_milestones start_date_is_fixed start_date_fixed start_date_from_milestones
start_date_sourcing_milestone_title start_date_sourcing_milestone_dates start_date_sourcing_milestone_title start_date_sourcing_milestone_dates
due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title
......
...@@ -7,7 +7,7 @@ import epicUtils from 'ee/epic/utils/epic_utils'; ...@@ -7,7 +7,7 @@ import epicUtils from 'ee/epic/utils/epic_utils';
import { dateTypes } from 'ee/epic/constants'; import { dateTypes } from 'ee/epic/constants';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { mockEpicMeta, mockEpicData, mockParentEpic } from '../mock_data'; import { mockEpicMeta, mockEpicData, mockAncestors } from '../mock_data';
describe('EpicSidebarComponent', () => { describe('EpicSidebarComponent', () => {
const originalUserId = gon.current_user_id; const originalUserId = gon.current_user_id;
...@@ -19,7 +19,7 @@ describe('EpicSidebarComponent', () => { ...@@ -19,7 +19,7 @@ describe('EpicSidebarComponent', () => {
store = createStore(); store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta); store.dispatch('setEpicMeta', mockEpicMeta);
store.dispatch('setEpicData', mockEpicData); store.dispatch('setEpicData', mockEpicData);
store.state.parent = mockParentEpic; store.state.ancestors = mockAncestors;
vm = mountComponentWithStore(Component, { vm = mountComponentWithStore(Component, {
store, store,
...@@ -204,18 +204,27 @@ describe('EpicSidebarComponent', () => { ...@@ -204,18 +204,27 @@ describe('EpicSidebarComponent', () => {
expect(vm.$el.querySelector('.js-labels-block')).not.toBeNull(); expect(vm.$el.querySelector('.js-labels-block')).not.toBeNull();
}); });
it('renders parent epic link element', done => { it('renders ancestors list', done => {
store.dispatch('toggleSidebarFlag', false); store.dispatch('toggleSidebarFlag', false);
vm.$nextTick() vm.$nextTick()
.then(() => { .then(() => {
const parentEpicEl = vm.$el.querySelector('.block.parent-epic'); const ancestorsEl = vm.$el.querySelector('.block.ancestors');
expect(parentEpicEl).not.toBeNull(); const reverseAncestors = [...mockAncestors].reverse();
expect(parentEpicEl.querySelector('.title').innerText.trim()).toBe('Parent epic');
expect(parentEpicEl.querySelector('.value').innerText.trim()).toBe(mockParentEpic.title); const getEls = selector => Array.from(ancestorsEl.querySelectorAll(selector));
expect(parentEpicEl.querySelector('.value a').getAttribute('href')).toBe(
mockParentEpic.url, expect(ancestorsEl).not.toBeNull();
expect(getEls('li.vertical-timeline-row').length).toBe(reverseAncestors.length);
expect(getEls('a').map(el => el.innerText.trim())).toEqual(
reverseAncestors.map(a => a.title),
);
expect(getEls('li.vertical-timeline-row a').map(a => a.getAttribute('href'))).toEqual(
reverseAncestors.map(a => a.url),
); );
}) })
.then(done) .then(done)
......
...@@ -45,8 +45,15 @@ export const mockLabels = [ ...@@ -45,8 +45,15 @@ export const mockLabels = [
}, },
]; ];
export const mockParentEpic = { export const mockAncestors = [
id: 1, {
title: 'Sample Parent Epic', id: 1,
url: '/groups/gitlab-org/-/epics/6', title: 'Parent epic',
}; url: '/groups/gitlab-org/-/epics/6',
},
{
id: 2,
title: 'Parent epic 2',
url: '/groups/gitlab-org/-/epics/7',
},
];
...@@ -260,23 +260,20 @@ describe('Epic Store Getters', () => { ...@@ -260,23 +260,20 @@ describe('Epic Store Getters', () => {
}); });
}); });
describe('parentEpic', () => { describe('ancestors', () => {
it('returns `parent` from state when parent is not null', () => { it('returns `ancestors` from state when ancestors is not null', () => {
const parent = getters.parentEpic({ const ancestors = getters.ancestors({
parent: { ancestors: [{ id: 1, title: 'Parent' }],
id: 1,
},
}); });
expect(parent.id).toBe(1); expect(ancestors.length).toBe(1);
}); });
it('returns empty object when `parent` within state is null', () => { it('returns empty array when `ancestors` within state is null', () => {
const parent = getters.parentEpic({ const ancestors = getters.ancestors({});
parent: null,
});
expect(parent).not.toBeNull(); expect(ancestors).not.toBeNull();
expect(ancestors.length).toBe(0);
}); });
}); });
}); });
import Vue from 'vue';
import ancestorsTree from 'ee/sidebar/components/ancestors_tree/ancestors_tree.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('AncestorsTreeContainer', () => {
let vm;
const ancestors = [
{ id: 1, url: '', title: 'A', state: 'open' },
{ id: 2, url: '', title: 'B', state: 'open' },
];
beforeEach(() => {
const AncestorsTreeContainer = Vue.extend(ancestorsTree);
vm = mountComponent(AncestorsTreeContainer, { ancestors, isFetching: false });
});
afterEach(() => {
vm.$destroy();
});
it('renders all ancestors rows', () => {
expect(vm.$el.querySelectorAll('.vertical-timeline-row').length).toBe(ancestors.length);
});
it('renders tooltip with the immediate parent', () => {
expect(vm.$el.querySelector('.collapse-truncated-title').innerText.trim()).toBe(
ancestors.slice(-1)[0].title,
);
});
it('does not render timeline when fetching', done => {
vm.$props.isFetching = true;
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.vertical-timeline')).toBeNull();
expect(vm.$el.querySelector('.value')).toBeNull();
})
.then(done)
.catch(done.fail);
});
it('render `None` when ancestors is an empty array', done => {
vm.$props.ancestors = [];
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.vertical-timeline')).toBeNull();
expect(vm.$el.querySelector('.value')).not.toBeNull();
})
.then(done)
.catch(done.fail);
});
it('render loading icon when isFetching is true', done => {
vm.$props.isFetching = true;
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeDefined();
})
.then(done)
.catch(done.fail);
});
});
...@@ -87,13 +87,13 @@ describe Epic do ...@@ -87,13 +87,13 @@ describe Epic do
end end
describe '#ancestors', :nested_groups do describe '#ancestors', :nested_groups do
let(:group) { create(:group) } set(:group) { create(:group) }
let(:epic1) { create(:epic, group: group) } set(:epic1) { create(:epic, group: group) }
let(:epic2) { create(:epic, group: group, parent: epic1) } set(:epic2) { create(:epic, group: group, parent: epic1) }
let(:epic3) { create(:epic, group: group, parent: epic2) } set(:epic3) { create(:epic, group: group, parent: epic2) }
it 'returns all ancestors for an epic' do it 'returns all ancestors for an epic' do
expect(epic3.ancestors).to match_array([epic1, epic2]) expect(epic3.ancestors).to eq [epic2, epic1]
end end
it 'returns an empty array if an epic does not have any parent' do it 'returns an empty array if an epic does not have any parent' do
......
...@@ -1000,6 +1000,9 @@ msgstr "" ...@@ -1000,6 +1000,9 @@ msgstr ""
msgid "Analytics" msgid "Analytics"
msgstr "" msgstr ""
msgid "Ancestors"
msgstr ""
msgid "Anonymous" msgid "Anonymous"
msgstr "" msgstr ""
...@@ -7086,9 +7089,6 @@ msgstr "" ...@@ -7086,9 +7089,6 @@ msgstr ""
msgid "Parameter" msgid "Parameter"
msgstr "" msgstr ""
msgid "Parent epic"
msgstr ""
msgid "Part of merge request changes" msgid "Part of merge request changes"
msgstr "" msgstr ""
......
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