Commit 81ba3277 authored by Jan Provaznik's avatar Jan Provaznik Committed by Douwe Maan

[Backend] Expose list of epic ancestors for epic

It passes to frontend a list of all epic ancestors. This list
is ordered (from closest parent). Also epic groups are
preloaded to avoid N+1 queries.
parent 8fa83aed
......@@ -157,3 +157,55 @@
.sidebar-collapsed-icon .sidebar-collapsed-value {
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>
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';
......@@ -23,7 +23,7 @@ export default {
SidebarDatePicker,
SidebarDatePickerCollapsed,
SidebarLabels,
SidebarParentEpic,
AncestorsTree,
SidebarParticipants,
SidebarSubscription,
},
......@@ -56,7 +56,7 @@ export default {
'dueDateTimeFromMilestones',
'dueDateTime',
'dueDateForCollapsedSidebar',
'parentEpic',
'ancestors',
]),
},
mounted() {
......@@ -184,8 +184,8 @@ export default {
@toggleCollapse="toggleSidebar({ sidebarCollapsed })"
/>
<sidebar-labels :can-update="canUpdate" :sidebar-collapsed="sidebarCollapsed" />
<div class="block parent-epic">
<sidebar-parent-epic :block-title="__('Parent epic')" :initial-epic="parentEpic" />
<div class="block ancestors">
<ancestors-tree :ancestors="ancestors" :is-fetching="false" />
</div>
<div class="block participants">
<sidebar-participants
......
......@@ -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
export default () => {};
......@@ -55,7 +55,7 @@ export default () => ({
dueDateFromMilestones: '',
dueDate: '',
labels: [],
parent: null,
ancestors: [],
participants: [],
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
module EpicsHelper
include EntityDateHelper
# rubocop: disable Metrics/AbcSize
def epic_show_app_data(epic, opts)
group = epic.group
......@@ -10,7 +12,7 @@ module EpicsHelper
epic_id: epic.id,
created: epic.created_at,
author: epic_author(epic, opts),
parent: epic_parent(epic.parent),
ancestors: epic_ancestors(epic.ancestors.inc_group),
todo_exists: todo.present?,
todo_path: group_todos_path(group),
start_date: epic.start_date,
......@@ -75,14 +77,17 @@ module EpicsHelper
}
end
def epic_parent(epic)
return unless epic
{
id: epic.id,
title: epic.title,
url: epic_path(epic)
}
def epic_ancestors(epics)
epics.map do |epic|
{
id: epic.id,
title: epic.title,
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
def epic_endpoint_query_params(opts)
......
......@@ -48,6 +48,7 @@ module EE
alias_attribute :parent_ids, :parent_id
scope :in_parents, -> (parent_ids) { where(parent_id: parent_ids) }
scope :inc_group, -> { includes(:group) }
scope :order_start_or_end_date_asc, -> do
# mysql returns null values first in opposite to postgres which
......@@ -280,7 +281,7 @@ module EE
def ancestors
return self.class.none unless parent_id
hierarchy.ancestors
hierarchy.ancestors(hierarchy_order: :asc)
end
def descendants
......
---
title: Add Ancestors in Epic Sidebar
merge_request: 9817
author:
type: added
......@@ -8,7 +8,7 @@ describe EpicsHelper do
let(:group) { create(:group) }
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(: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
create(
:epic,
......@@ -34,7 +34,7 @@ describe EpicsHelper do
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(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_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
......@@ -47,11 +47,6 @@ describe EpicsHelper do
'username' => "@#{user.username}",
'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_sourcing_milestone_title']).to eq(milestone1.title)
expect(meta_data['start_date_sourcing_milestone_dates']['start_date']).to eq(milestone1.start_date.to_s)
......@@ -62,6 +57,32 @@ describe EpicsHelper do
expect(meta_data['due_date_sourcing_milestone_dates']['due_date']).to eq(milestone2.due_date.to_s)
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
let(:milestone) { create(:milestone, title: 'make me a sandwich') }
......@@ -85,7 +106,7 @@ describe EpicsHelper do
meta_data = JSON.parse(data[:meta])
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_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
......
......@@ -7,7 +7,7 @@ import epicUtils from 'ee/epic/utils/epic_utils';
import { dateTypes } from 'ee/epic/constants';
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', () => {
const originalUserId = gon.current_user_id;
......@@ -19,7 +19,7 @@ describe('EpicSidebarComponent', () => {
store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta);
store.dispatch('setEpicData', mockEpicData);
store.state.parent = mockParentEpic;
store.state.ancestors = mockAncestors;
vm = mountComponentWithStore(Component, {
store,
......@@ -204,18 +204,27 @@ describe('EpicSidebarComponent', () => {
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);
vm.$nextTick()
.then(() => {
const parentEpicEl = vm.$el.querySelector('.block.parent-epic');
const ancestorsEl = vm.$el.querySelector('.block.ancestors');
expect(parentEpicEl).not.toBeNull();
expect(parentEpicEl.querySelector('.title').innerText.trim()).toBe('Parent epic');
expect(parentEpicEl.querySelector('.value').innerText.trim()).toBe(mockParentEpic.title);
expect(parentEpicEl.querySelector('.value a').getAttribute('href')).toBe(
mockParentEpic.url,
const reverseAncestors = [...mockAncestors].reverse();
const getEls = selector => Array.from(ancestorsEl.querySelectorAll(selector));
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)
......
......@@ -45,8 +45,15 @@ export const mockLabels = [
},
];
export const mockParentEpic = {
id: 1,
title: 'Sample Parent Epic',
url: '/groups/gitlab-org/-/epics/6',
};
export const mockAncestors = [
{
id: 1,
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', () => {
});
});
describe('parentEpic', () => {
it('returns `parent` from state when parent is not null', () => {
const parent = getters.parentEpic({
parent: {
id: 1,
},
describe('ancestors', () => {
it('returns `ancestors` from state when ancestors is not null', () => {
const ancestors = getters.ancestors({
ancestors: [{ id: 1, title: 'Parent' }],
});
expect(parent.id).toBe(1);
expect(ancestors.length).toBe(1);
});
it('returns empty object when `parent` within state is null', () => {
const parent = getters.parentEpic({
parent: null,
});
it('returns empty array when `ancestors` within state is null', () => {
const ancestors = getters.ancestors({});
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
end
describe '#ancestors', :nested_groups do
let(:group) { create(:group) }
let(:epic1) { create(:epic, group: group) }
let(:epic2) { create(:epic, group: group, parent: epic1) }
let(:epic3) { create(:epic, group: group, parent: epic2) }
set(:group) { create(:group) }
set(:epic1) { create(:epic, group: group) }
set(:epic2) { create(:epic, group: group, parent: epic1) }
set(:epic3) { create(:epic, group: group, parent: epic2) }
it 'returns all ancestors for an epic' do
expect(epic3.ancestors).to match_array([epic1, epic2])
expect(epic3.ancestors).to eq [epic2, epic1]
end
it 'returns an empty array if an epic does not have any parent' do
......
......@@ -1000,6 +1000,9 @@ msgstr ""
msgid "Analytics"
msgstr ""
msgid "Ancestors"
msgstr ""
msgid "Anonymous"
msgstr ""
......@@ -7086,9 +7089,6 @@ msgstr ""
msgid "Parameter"
msgstr ""
msgid "Parent epic"
msgstr ""
msgid "Part of merge request changes"
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