Commit 8b506324 authored by Kushal Pandya's avatar Kushal Pandya Committed by Phil Hughes

Add support for default height constraint

Roadmap would occupy entire window innerHeight but
when `defaultInnerHeight` prop is set, it can use that instead
of full window height.
parent a220e0a6
......@@ -595,7 +595,6 @@
color: $gl-text-color;
}
.git-merge-container {
justify-content: space-between;
flex: 1;
......@@ -805,7 +804,8 @@
}
}
.merge-request-tabs-holder {
.merge-request-tabs-holder,
.epic-tabs-holder {
top: $header-height;
z-index: 250;
background-color: $white-light;
......@@ -823,11 +823,6 @@
@include media-breakpoint-down(xs) {
right: 0;
}
.merge-request-tabs-container {
padding-left: $gl-padding;
padding-right: $gl-padding;
}
}
.nav-links {
......@@ -835,11 +830,21 @@
}
}
.with-performance-bar .merge-request-tabs-holder {
top: $header-height + $performance-bar-height;
.merge-request-tabs-holder.affix .merge-request-tabs-container,
.epic-tabs-holder.affix .epic-tabs-container {
padding-left: $gl-padding;
padding-right: $gl-padding;
}
.merge-request-tabs {
.with-performance-bar {
.merge-request-tabs-holder,
.epic-tabs-holder {
top: $header-height + $performance-bar-height;
}
}
.merge-request-tabs,
.epic-tabs {
display: flex;
flex-wrap: nowrap;
margin-bottom: 0;
......@@ -847,7 +852,8 @@
}
.limit-container-width {
.merge-request-tabs-container {
.merge-request-tabs-container,
.epic-tabs-container {
max-width: $limited-layout-width;
margin-left: auto;
margin-right: auto;
......@@ -860,7 +866,8 @@
}
}
.merge-request-tabs-container {
.merge-request-tabs-container,
.epic-tabs-container {
display: flex;
justify-content: space-between;
......@@ -878,10 +885,9 @@
}
.limit-container-width:not(.container-limited) {
.merge-request-tabs-holder:not(.affix) {
.merge-request-tabs-container {
max-width: $limited-layout-width - ($gl-padding * 2);
}
.merge-request-tabs-holder:not(.affix) .merge-request-tabs-container,
.epic-tabs-holder:not(.affix) .epic-tabs-container {
max-width: $limited-layout-width - ($gl-padding * 2);
}
}
......
import $ from 'jquery';
import initRoadmap from 'ee/roadmap/index';
export default class EpicTabs {
constructor() {
this.wrapper = document.querySelector('.content-wrapper .container-fluid:not(.breadcrumbs)');
this.epicTabs = this.wrapper.querySelector('.js-epic-tabs-container');
this.discussionFilterContainer = this.epicTabs.querySelector('.js-discussion-filter-container');
this.roadmapTabLoaded = false;
this.bindEvents();
}
bindEvents() {
const $roadmapTab = $('#roadmap-tab', this.epicTabs);
$roadmapTab.on('show.bs.tab', this.onRoadmapShow.bind(this));
$roadmapTab.on('hide.bs.tab', this.onRoadmapHide.bind(this));
}
onRoadmapShow() {
this.wrapper.classList.remove('container-limited');
this.discussionFilterContainer.classList.add('hidden');
if (!this.roadmapTabLoaded) {
initRoadmap();
this.roadmapTabLoaded = true;
}
}
onRoadmapHide() {
this.wrapper.classList.add('container-limited');
this.discussionFilterContainer.classList.remove('hidden');
}
}
import ZenMode from '~/zen_mode';
import ShortcutsEpic from 'ee/behaviors/shortcuts/shortcuts_epic';
import EpicTabs from 'ee/epic/epic_tabs';
import initEpicApp from 'ee/epic/epic_bundle';
import '~/notes/index';
......@@ -7,4 +8,5 @@ document.addEventListener('DOMContentLoaded', () => {
new ZenMode(); // eslint-disable-line no-new
initEpicApp();
new ShortcutsEpic(); // eslint-disable-line no-new
new EpicTabs(); // eslint-disable-line no-new
});
......@@ -46,6 +46,7 @@ export default {
'epicsFetchForTimeframeInProgress',
'epicsFetchResultEmpty',
'epicsFetchFailure',
'isChildEpics',
]),
timeframeStart() {
return this.timeframe[0];
......@@ -171,6 +172,7 @@ export default {
:has-filters-applied="hasFiltersApplied"
:new-epic-endpoint="newEpicEndpoint"
:empty-state-illustration-path="emptyStateIllustrationPath"
:is-child-epics="isChildEpics"
/>
</div>
</template>
......@@ -32,6 +32,11 @@ export default {
type: String,
required: true,
},
isChildEpics: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
timeframeRange() {
......@@ -78,6 +83,20 @@ export default {
return s__('GroupRoadmap|The roadmap shows the progress of your epics along a timeline');
},
subMessage() {
if (this.isChildEpics) {
return sprintf(
s__(
'GroupRoadmap|To view the roadmap, add a start or due date to one of the %{linkStart}child epics%{linkEnd}.',
),
{
linkStart:
'<a href="https://docs.gitlab.com/ee/user/group/epics/#multi-level-child-epics" target="_blank" rel="noopener noreferrer nofollow">',
linkEnd: '</a>',
},
false,
);
}
if (this.hasFiltersApplied) {
return sprintf(emptyStateWithFilters, {
startDate: this.timeframeRange.startDate,
......
<script>
import { mapState } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
import { isInViewport } from '~/lib/utils/common_utils';
......@@ -41,6 +42,7 @@ export default {
};
},
computed: {
...mapState(['defaultInnerHeight']),
containerStyles() {
return {
width: `${this.shellWidth}px`,
......@@ -73,7 +75,7 @@ export default {
// before setting shellWidth
// see https://vuejs.org/v2/api/#Vue-nextTick
if (this.$el.parentElement) {
this.shellHeight = window.innerHeight - this.$el.offsetTop;
this.shellHeight = (this.defaultInnerHeight || window.innerHeight) - this.$el.offsetTop;
this.noScroll = this.shellHeight > EPIC_ITEM_HEIGHT * (this.epics.length + 1);
this.shellWidth = this.getShellWidth(this.noScroll);
......
......@@ -81,7 +81,7 @@ export default {
},
handleEpicsListScroll() {
const indicatorX = this.$el.getBoundingClientRect().x;
const rootOffsetLeft = this.$root.$el.offsetLeft;
const rootOffsetLeft = this.$root.$el.parentElement.offsetLeft;
// 3px to compensate size of bubble on top of Indicator
this.todayBarReady = indicatorX - rootOffsetLeft >= EPIC_DETAILS_CELL_WIDTH + 3;
......
......@@ -63,6 +63,8 @@ export default () => {
return {
emptyStateIllustrationPath: dataset.emptyStateIllustration,
hasFiltersApplied: parseBoolean(dataset.hasFiltersApplied),
defaultInnerHeight: Number(dataset.innerHeight),
isChildEpics: parseBoolean(dataset.childEpics),
currentGroupId: parseInt(dataset.groupId, 0),
newEpicEndpoint: dataset.newEpicEndpoint,
epicsState: dataset.epicsState,
......@@ -83,6 +85,8 @@ export default () => {
basePath: this.basePath,
filterQueryString: this.filterQueryString,
initialEpicsPath: this.initialEpicsPath,
defaultInnerHeight: this.defaultInnerHeight,
isChildEpics: this.isChildEpics,
});
},
methods: {
......
......@@ -15,6 +15,8 @@ export default () => ({
sortedBy: '',
// UI Flags
defaultInnerHeight: 0,
isChildEpics: false,
epicsFetchInProgress: false,
epicsFetchForTimeframeInProgress: false,
epicsFetchFailure: false,
......
......@@ -399,9 +399,11 @@ export const getEpicsPathForPreset = ({
end.setDate(end.getDate() + 6);
}
epicsPath += epicsPath.indexOf('?') === -1 ? '?' : '&';
const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`;
const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`;
epicsPath += `?state=${epicsState}&start_date=${startDate}&end_date=${endDate}`;
epicsPath += `state=${epicsState}&start_date=${startDate}&end_date=${endDate}`;
if (filterQueryString) {
epicsPath += `&${filterQueryString}`;
......
......@@ -43,6 +43,21 @@
}
}
.epic-tabs-content {
.roadmap-container {
min-height: 600px;
.roadmap-shell {
border: 1px solid $white-dark;
border-top: 0;
}
.empty-state {
margin-top: 0;
}
}
}
@include media-breakpoint-down(xs) {
.epic-page-container {
.detail-page-header {
......
......@@ -53,3 +53,10 @@
.add-issuable-form-actions {
margin-top: $gl-padding;
}
.limit-container-width {
.epic-page-container .issuable-details,
.emoji-list-container {
@include fixed-width-container;
}
}
......@@ -3,6 +3,7 @@
class EpicsFinder < IssuableFinder
def self.scalar_params
@scalar_params ||= %i[
parent_id
author_id
author_username
label_name
......
......@@ -4,4 +4,8 @@ module RoadmapsHelper
def roadmap_layout
(current_user&.roadmap_layout || params[:layout].presence || EE::User::DEFAULT_ROADMAP_LAYOUT).upcase
end
def roadmap_sort_order
current_user&.user_preference&.roadmaps_sort || sort_value_start_date_soon
end
end
......@@ -13,12 +13,42 @@
#epic-app-root{ data: epic_show_app_data(@epic, author_icon: avatar_icon_for_user(@epic.author), initial: issuable_initial_data(@epic)) }
.content-block.emoji-block
.row
.col-sm-6.col-md-8.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @epic, inline: true
.col-sm-6.col-md-4.d-flex.justify-content-sm-end.align-items-md-top.mt-2.mt-sm-1
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@epic), notes_filters: UserPreference.notes_filters.to_json } }
.content-block.content-block-small.emoji-list-container.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @epic, inline: true
%section.issuable-discussion
= render 'discussion'
.epic-tabs-holder
.epic-tabs-container.js-epic-tabs-container
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
%ul.epic-tabs.nav-tabs.nav.nav-links.scrolling-tabs
%li.notes-tab.qa-notes-tab
%a#discussion-tab.active{ href: '#discussion', data: { toggle: 'tab' } }
= _('Discussion')
%li.roadmap-tab
%a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } }
= _('Roadmap')
.d-inline-flex.flex-wrap
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@epic),
notes_filters: UserPreference.notes_filters.to_json } }
.tab-content.epic-tabs-content.js-epic-tabs-content
#discussion.tab-pane.show.active
.row
%section.col-md-12
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-discussion
= render 'discussion'
#roadmap.tab-pane
.row
%section.col-md-12
#js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json),
group_id: @group.id,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: 'false',
new_epic_endpoint: group_epics_path(@group),
preset_type: roadmap_layout,
epics_state: 'all',
sorted_by: roadmap_sort_order,
inner_height: '600',
child_epics: 'true' } }
---
title: Add Roadmap to Epic page
merge_request: 10488
author:
type: added
......@@ -148,6 +148,21 @@ describe Groups::EpicsController do
expect(item['web_url']).to eq(group_epic_path(group, epic))
end
context 'with parent_id filter' do
let(:parent_epic) { create(:epic, group: group) }
it 'returns child epics of the given parent' do
child_epics = create_list(:epic, 2, group: group, parent: parent_epic)
# descendant epic that should not be included
create(:epic, group: group, parent: child_epics.first)
get :index, params: { group_id: group, parent_id: parent_epic.id }, format: :json
expect(json_response.size).to eq(2)
expect(json_response.map { |e| e['id'] }).to match_array(child_epics.map(&:id))
end
end
context 'using label_name filter' do
let(:label) { create(:label) }
let!(:labeled_epic) { create(:labeled_epic, group: group, labels: [label]) }
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Epic show', :js do
let(:user) { create(:user, name: 'Rick Sanchez', username: 'rick.sanchez') }
let(:group) { create(:group, :public) }
let(:label) { create(:group_label, group: group, title: 'bug') }
let(:note_text) { 'Contemnit enim disserendi elegantiam.' }
let(:epic_title) { 'Sample epic' }
let(:markdown) do
<<-MARKDOWN.strip_heredoc
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Nos commodius agimus.
Ex rebus enim timiditas, non ex vocabulis nascitur.
Ita prorsus, inquam; Duo Reges: constructio interrete.
MARKDOWN
end
let(:epic) { create(:epic, group: group, title: epic_title, description: markdown, author: user) }
let!(:child_epic_a) { create(:epic, group: group, title: 'Child epic A', description: markdown, parent: epic, start_date: 50.days.ago, end_date: 10.days.ago) }
let!(:child_epic_b) { create(:epic, group: group, title: 'Child epic B', description: markdown, parent: epic, start_date: 100.days.ago, end_date: 20.days.ago) }
before do
group.add_developer(user)
stub_licensed_features(epics: true)
sign_in(user)
visit group_epic_path(group, epic)
end
describe 'Epic metadata' do
it 'shows epic status, date and author in header' do
page.within('.epic-page-container .detail-page-header-body') do
expect(find('.issuable-status-box > span')).to have_content('Open')
expect(find('.issuable-meta')).to have_content('Opened just now by')
expect(find('.issuable-meta .js-user-avatar-link-username')).to have_content('Rick Sanchez')
end
end
it 'shows epic title and description' do
page.within('.epic-page-container .detail-page-description') do
expect(find('.title-container .title')).to have_content(epic_title)
expect(find('.description .md')).to have_content(markdown)
end
end
it 'shows epic tabs' do
page.within('.js-epic-tabs-container') do
expect(find('.epic-tabs #discussion-tab')).to have_content('Discussion')
expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap')
end
end
it 'shows epic discussion filter dropdown' do
page.within('.js-epic-tabs-container') do
expect(find('.js-discussion-filter-container #discussion-filter-dropdown')).to have_content('Show all activity')
end
end
end
describe 'Epic child epics' do
it 'shows child epics list' do
page.within('.js-related-epics-block') do
expect(find('.issue-count-badge-count')).to have_content('2')
expect(find('.js-related-issues-token-list-item:nth-child(1) .sortable-link')).to have_content('Child epic B')
expect(find('.js-related-issues-token-list-item:nth-child(2) .sortable-link')).to have_content('Child epic A')
end
end
end
describe 'Roadmap tab' do
before do
find('.js-epic-tabs-container #roadmap-tab').click
wait_for_requests
end
it 'shows Roadmap timeline with child epics' do
page.within('.js-epic-tabs-content #roadmap') do
expect(page).to have_selector('.roadmap-container .roadmap-shell')
page.within('.roadmap-shell .epics-list-section') do
expect(find('.epics-list-item:nth-child(1) .epic-title a')).to have_content('Child epic B')
expect(find('.epics-list-item:nth-child(2) .epic-title a')).to have_content('Child epic A')
end
end
end
it 'does not show discussion filter dropdown' do
expect(find('.js-epic-tabs-container')).to have_selector('.js-discussion-filter-container', visible: false)
end
it 'has no limit on container width' do
expect(find('.content-wrapper .container-fluid:not(.breadcrumbs)')[:class]).not_to include('container-limited')
end
end
end
......@@ -2,11 +2,11 @@
require 'spec_helper'
describe RoadmapsHelper do
describe '#roadmap_layout' do
before do
allow(helper).to receive(:current_user) { user }
end
before do
allow(helper).to receive(:current_user) { user }
end
describe '#roadmap_layout' do
context 'guest' do
let(:user) { nil }
......@@ -34,4 +34,40 @@ describe RoadmapsHelper do
end
end
end
describe '#roadmap_sort_order' do
let(:user_preference) { double(:user_preference) }
before do
allow(user).to receive(:user_preference).and_return(user_preference)
end
context 'guest' do
let(:user) { nil }
it 'returns default sort order' do
expect(helper.roadmap_sort_order).to eq('start_date_asc')
end
end
context 'user without preferences set' do
let(:user) { double(:user) }
it 'returns default sort order' do
expect(user_preference).to receive(:roadmaps_sort).and_return(nil)
expect(helper.roadmap_sort_order).to eq('start_date_asc')
end
end
context 'user with preference set' do
let(:user) { double(:user) }
it 'returns saved user preference' do
expect(user_preference).to receive(:roadmaps_sort).and_return('end_date_asc')
expect(helper.roadmap_sort_order).to eq('end_date_asc')
end
end
end
end
......@@ -157,6 +157,20 @@ describe('EpicsListEmptyComponent', () => {
.catch(done.fail);
});
});
describe('with child epics context', () => {
it('returns empty state sub-message when `isChildEpics` is set to `true`', done => {
vm.isChildEpics = true;
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe(
'To view the roadmap, add a start or due date to one of the <a href="https://docs.gitlab.com/ee/user/group/epics/#multi-level-child-epics" target="_blank" rel="noopener noreferrer nofollow">child epics</a>.',
);
})
.then(done)
.catch(done.fail);
});
});
});
describe('timeframeRange', () => {
......
import Vue from 'vue';
import roadmapShellComponent from 'ee/roadmap/components/roadmap_shell.vue';
import createStore from 'ee/roadmap/store';
import eventHub from 'ee/roadmap/event_hub';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { mockEpic, mockTimeframeInitialDate, mockGroupId } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = (
{ epics = [mockEpic], timeframe = mockTimeframeMonths, currentGroupId = mockGroupId },
{
epics = [mockEpic],
timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId,
defaultInnerHeight = 0,
},
el,
) => {
const Component = Vue.extend(roadmapShellComponent);
return mountComponent(
Component,
{
const store = createStore();
store.dispatch('setInitialData', {
defaultInnerHeight,
});
return mountComponentWithStore(Component, {
el,
store,
props: {
presetType: PRESET_TYPES.MONTHS,
epics,
timeframe,
currentGroupId,
},
el,
);
});
};
describe('RoadmapShellComponent', () => {
......
......@@ -22,6 +22,7 @@ describe('Roadmap Store Mutations', () => {
currentGroupId: mockGroupId,
sortedBy: mockSortedBy,
initialEpicsPath: epicsPath,
defaultInnerHeight: 600,
extendedTimeframe: [],
filterQueryString: '',
epicsState: 'all',
......
......@@ -5346,6 +5346,9 @@ msgstr ""
msgid "GroupRoadmap|The roadmap shows the progress of your epics along a timeline"
msgstr ""
msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of the %{linkStart}child epics%{linkEnd}."
msgstr ""
msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from %{startDate} to %{endDate}."
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