Commit c5b22ef8 authored by Kushal Pandya's avatar Kushal Pandya

Hide EEU features for Epics in EEP

Hides ability to use sub-epics (an EE Ultimate feature) for
users who are on EE Premium.
parent 33e7d73d
......@@ -30,6 +30,7 @@ export default {
computed: {
...mapState([
'canUpdate',
'allowSubEpics',
'sidebarCollapsed',
'participants',
'startDateSourcingMilestoneTitle',
......@@ -186,7 +187,7 @@ export default {
@toggleCollapse="toggleSidebar({ sidebarCollapsed })"
/>
<sidebar-labels :can-update="canUpdate" :sidebar-collapsed="sidebarCollapsed" />
<div class="block ancestors">
<div v-if="allowSubEpics" class="block ancestors">
<ancestors-tree :ancestors="ancestors" :is-fetching="false" />
</div>
<div class="block participants">
......
......@@ -3,7 +3,7 @@ import { mapActions } from 'vuex';
import Cookies from 'js-cookie';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import createStore from './store';
import EpicApp from './components/epic_app.vue';
......@@ -54,7 +54,10 @@ export default (epicCreate = false) => {
store,
components: { EpicApp },
created() {
this.setEpicMeta(epicMeta);
this.setEpicMeta({
...epicMeta,
allowSubEpics: parseBoolean(el.dataset.allowSubEpics),
});
this.setEpicData(epicData);
},
methods: {
......
import $ from 'jquery';
import { parseBoolean } from '~/lib/utils/common_utils';
import initRelatedItemsTree from 'ee/related_items_tree/related_items_tree_bundle';
import initRoadmap from 'ee/roadmap/roadmap_bundle';
export default class EpicTabs {
constructor() {
......@@ -8,12 +8,31 @@ export default class EpicTabs {
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');
const allowSubEpics = parseBoolean(this.epicTabs.dataset.allowSubEpics);
initRelatedItemsTree();
this.roadmapTabLoaded = false;
// We need to execute Roadmap tab related
// logic only when sub-epics feature is available.
if (allowSubEpics) {
this.roadmapTabLoaded = false;
this.bindEvents();
this.loadRoadmapBundle();
this.bindEvents();
}
}
/**
* This method loads Roadmap app bundle asynchronously.
*
* @param {boolean} allowSubEpics
*/
loadRoadmapBundle() {
import('ee/roadmap/roadmap_bundle')
.then(roadmapBundle => {
this.initRoadmap = roadmapBundle.default;
})
.catch(() => {});
}
bindEvents() {
......@@ -26,7 +45,7 @@ export default class EpicTabs {
onRoadmapShow() {
this.wrapper.classList.remove('container-limited');
if (!this.roadmapTabLoaded) {
initRoadmap();
this.initRoadmap();
this.roadmapTabLoaded = true;
}
}
......
......@@ -19,6 +19,7 @@ export default () => ({
canUpdate: false,
canDestroy: false,
canAdmin: false,
allowSubEpics: false,
// Epic Information
epicId: 0,
......
......@@ -17,7 +17,7 @@ export default {
EpicActionsSplitButton,
},
computed: {
...mapState(['parentItem', 'descendantCounts']),
...mapState(['parentItem', 'descendantCounts', 'allowSubEpics']),
totalEpicsCount() {
return this.descendantCounts.openedEpics + this.descendantCounts.closedEpics;
},
......@@ -51,7 +51,7 @@ export default {
<div class="card-header d-flex px-2">
<div class="d-inline-flex flex-grow-1 lh-100 align-middle">
<gl-tooltip :target="() => $refs.countBadge">
<p class="font-weight-bold m-0">
<p v-if="allowSubEpics" class="font-weight-bold m-0">
{{ __('Epics') }} &#8226;
<span class="text-secondary-400 font-weight-normal"
>{{
......@@ -75,11 +75,11 @@ export default {
</p>
</gl-tooltip>
<div ref="countBadge" class="issue-count-badge">
<span class="d-inline-flex align-items-center">
<span v-if="allowSubEpics" class="d-inline-flex align-items-center">
<icon :size="16" name="epic" class="text-secondary mr-1" />
{{ totalEpicsCount }}
</span>
<span class="ml-2 d-inline-flex align-items-center">
<span class="d-inline-flex align-items-center" :class="{ 'ml-2': allowSubEpics }">
<icon :size="16" name="issues" class="text-secondary mr-1" />
{{ totalIssuesCount }}
</span>
......@@ -88,6 +88,7 @@ export default {
<div class="d-inline-flex js-button-container">
<template v-if="parentItem.userPermissions.adminEpic">
<epic-actions-split-button
v-if="allowSubEpics"
class="qa-add-epics-button"
@showAddEpicForm="showAddEpicForm"
@showCreateEpicForm="showCreateEpicForm"
......
......@@ -16,7 +16,15 @@ export default () => {
return false;
}
const { id, iid, fullPath, autoCompleteEpics, autoCompleteIssues, userSignedIn } = el.dataset;
const {
id,
iid,
fullPath,
autoCompleteEpics,
autoCompleteIssues,
userSignedIn,
allowSubEpics,
} = el.dataset;
const initialData = JSON.parse(el.dataset.initial);
Vue.component('tree-root', TreeRoot);
......@@ -46,6 +54,7 @@ export default () => {
autoCompleteEpics: parseBoolean(autoCompleteEpics),
autoCompleteIssues: parseBoolean(autoCompleteIssues),
userSignedIn: parseBoolean(userSignedIn),
allowSubEpics: parseBoolean(allowSubEpics),
});
},
methods: {
......
......@@ -12,6 +12,7 @@ export default {
autoCompleteIssues,
projectsEndpoint,
userSignedIn,
allowSubEpics,
},
) {
state.epicsEndpoint = epicsEndpoint;
......@@ -20,6 +21,7 @@ export default {
state.autoCompleteIssues = autoCompleteIssues;
state.projectsEndpoint = projectsEndpoint;
state.userSignedIn = userSignedIn;
state.allowSubEpics = allowSubEpics;
},
[types.SET_INITIAL_PARENT_ITEM](state, data) {
......
......@@ -36,6 +36,7 @@ export default () => ({
showCreateEpicForm: false,
autoCompleteEpics: false,
autoCompleteIssues: false,
allowSubEpics: false,
removeItemModalProps: {
parentItem: {},
item: {},
......
......@@ -3,6 +3,9 @@
- @content_class = "limit-container-width" unless fluid_layout
- epic_reference = @epic.to_reference
- sub_epics_feature_available = @group.feature_available?(:subepics)
- allow_sub_epics = sub_epics_feature_available ? 'true' : 'false'
- add_to_breadcrumbs _("Epics"), group_epics_path(@group)
- breadcrumb_title epic_reference
......@@ -11,17 +14,22 @@
- page_card_attributes @epic.card_attributes
#epic-app-root{ data: epic_show_app_data(@epic) }
#epic-app-root{ data: epic_show_app_data(@epic),
'data-allow-sub-epics' => allow_sub_epics }
.epic-tabs-holder
.epic-tabs-container.js-epic-tabs-container
.epic-tabs-container.js-epic-tabs-container{ data: { allow_sub_epics: allow_sub_epics } }
%ul.epic-tabs.nav-tabs.nav.nav-links.scrolling-tabs
%li.tree-tab
%a#tree-tab.active{ href: '#tree', data: { toggle: 'tab' } }
= _('Epics and Issues')
%li.roadmap-tab
%a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } }
= _('Roadmap')
- if sub_epics_feature_available
= _('Epics and Issues')
- else
= _('Issues')
- if sub_epics_feature_available
%li.roadmap-tab
%a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } }
= _('Roadmap')
.tab-content.epic-tabs-content.js-epic-tabs-content
#tree.tab-pane.show.active
......@@ -33,22 +41,24 @@
auto_complete_epics: 'true',
auto_complete_issues: 'true',
user_signed_in: current_user.present? ? 'true' : 'false',
allow_sub_epics: allow_sub_epics,
initial: issuable_initial_data(@epic).to_json } }
#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,
iid: @epic.iid,
full_path: @group.full_path,
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' } }
- if sub_epics_feature_available
#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,
iid: @epic.iid,
full_path: @group.full_path,
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' } }
%hr.epic-discussion-separator.mt-1.mb-0
.d-flex.justify-content-between.content-block.content-block-small.emoji-list-container.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @epic, inline: true
......
......@@ -5,7 +5,9 @@ require 'spec_helper'
describe 'Epic show', :js do
let(:user) { create(:user, name: 'Rick Sanchez', username: 'rick.sanchez') }
let(:group) { create(:group, :public) }
let(:public_project) { create(:project, :public, group: group) }
let(:label) { create(:group_label, group: group, title: 'bug') }
let(:public_issue) { create(:issue, project: public_project) }
let(:note_text) { 'Contemnit enim disserendi elegantiam.' }
let(:epic_title) { 'Sample epic' }
......@@ -22,83 +24,116 @@ describe 'Epic show', :js do
let!(:not_child) { create(:epic, group: group, title: 'not child epic', description: markdown, author: user, start_date: 50.days.ago, end_date: 10.days.ago) }
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) }
let!(:child_issue_a) { create(:epic_issue, epic: epic, issue: public_issue, relative_position: 1) }
before do
group.add_developer(user)
stub_licensed_features(epics: true)
stub_licensed_features(epics: true, subepics: 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')
describe 'when sub-epics feature is available' do
describe 'Epic metadata' do
it 'shows epic tabs `Epics and Issues` and `Roadmap`' do
page.within('.js-epic-tabs-container') do
expect(find('.epic-tabs #tree-tab')).to have_content('Epics and Issues')
expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap')
end
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.squish)
end
end
describe 'Epics and Issues tab' do
it 'shows Related items tree with child epics' do
page.within('.js-epic-tabs-content #tree') do
expect(page).to have_selector('.related-items-tree-container')
it 'shows epic tabs' do
page.within('.js-epic-tabs-container') do
expect(find('.epic-tabs #tree-tab')).to have_content('Epics and Issues')
expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap')
page.within('.related-items-tree-container') do
expect(page.find('.issue-count-badge')).to have_content('2')
expect(find('.tree-item:nth-child(1) .sortable-link')).to have_content('Child epic B')
expect(find('.tree-item:nth-child(2) .sortable-link')).to have_content('Child epic A')
end
end
end
end
it 'shows epic thread filter dropdown' do
page.within('.js-noteable-awards') do
expect(find('.js-discussion-filter-container #discussion-filter-dropdown')).to have_content('Show all activity')
describe 'Roadmap tab' do
before do
find('.js-epic-tabs-container #roadmap-tab').click
wait_for_requests
end
end
end
describe 'Epics and Issues tab' do
it 'shows Related items tree with child epics' do
page.within('.js-epic-tabs-content #tree') do
expect(page).to have_selector('.related-items-tree-container')
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('.related-items-tree-container') do
expect(page.find('.issue-count-badge')).to have_content('2')
expect(find('.tree-item:nth-child(1) .sortable-link')).to have_content('Child epic B')
expect(find('.tree-item:nth-child(2) .sortable-link')).to have_content('Child epic A')
page.within('.roadmap-shell .epics-list-section') do
expect(page).not_to have_content(not_child.title)
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 thread filter dropdown' do
expect(find('.js-noteable-awards')).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
describe 'Roadmap tab' do
describe 'when sub-epics feature not is available' do
before do
find('.js-epic-tabs-container #roadmap-tab').click
wait_for_requests
stub_licensed_features(epics: true, subepics: false)
visit group_epic_path(group, epic)
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')
describe 'Epic metadata' do
it 'shows epic tab `Issues`' do
page.within('.js-epic-tabs-container') do
expect(find('.epic-tabs #tree-tab')).to have_content('Issues')
end
end
end
page.within('.roadmap-shell .epics-list-section') do
expect(page).not_to have_content(not_child.title)
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')
describe 'Issues tab' do
it 'shows Related items tree with child epics' do
page.within('.js-epic-tabs-content #tree') do
expect(page).to have_selector('.related-items-tree-container')
page.within('.related-items-tree-container') do
expect(page.find('.issue-count-badge')).to have_content('1')
end
end
end
end
end
it 'does not show thread filter dropdown' do
expect(find('.js-noteable-awards')).to have_selector('.js-discussion-filter-container', visible: false)
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 'has no limit on container width' do
expect(find('.content-wrapper .container-fluid:not(.breadcrumbs)')[:class]).not_to include('container-limited')
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.squish)
end
end
it 'shows epic thread filter dropdown' do
page.within('.js-noteable-awards') do
expect(find('.js-discussion-filter-container #discussion-filter-dropdown')).to have_content('Show all activity')
end
end
end
end
......@@ -203,31 +203,50 @@ describe('EpicSidebarComponent', () => {
expect(vm.$el.querySelector('.js-labels-block')).not.toBeNull();
});
it('renders ancestors list', done => {
store.dispatch('toggleSidebarFlag', false);
describe('when sub-epics feature is available', () => {
it('renders ancestors list', done => {
store.dispatch('toggleSidebarFlag', false);
store.dispatch('setEpicMeta', {
...mockEpicMeta,
allowSubEpics: false,
});
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.block.ancestors')).toBeNull();
})
.then(done)
.catch(done.fail);
});
});
vm.$nextTick()
.then(() => {
const ancestorsEl = vm.$el.querySelector('.block.ancestors');
describe('when sub-epics feature is not available', () => {
it('does not render ancestors list', done => {
store.dispatch('toggleSidebarFlag', false);
const reverseAncestors = [...mockAncestors].reverse();
vm.$nextTick()
.then(() => {
const ancestorsEl = vm.$el.querySelector('.block.ancestors');
const getEls = selector => Array.from(ancestorsEl.querySelectorAll(selector));
const reverseAncestors = [...mockAncestors].reverse();
expect(ancestorsEl).not.toBeNull();
const getEls = selector => Array.from(ancestorsEl.querySelectorAll(selector));
expect(getEls('li.vertical-timeline-row').length).toBe(reverseAncestors.length);
expect(ancestorsEl).not.toBeNull();
expect(getEls('a').map(el => el.innerText.trim())).toEqual(
reverseAncestors.map(a => a.title),
);
expect(getEls('li.vertical-timeline-row').length).toBe(reverseAncestors.length);
expect(getEls('li.vertical-timeline-row a').map(a => a.getAttribute('href'))).toEqual(
reverseAncestors.map(a => a.url),
);
})
.then(done)
.catch(done.fail);
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)
.catch(done.fail);
});
});
it('renders participants list element', () => {
......
......@@ -5,9 +5,12 @@ const metaFixture = getJSONFixture('epic/mock_meta.json');
const meta = JSON.parse(metaFixture.meta);
const initial = JSON.parse(metaFixture.initial);
export const mockEpicMeta = convertObjectPropsToCamelCase(meta, {
deep: true,
});
export const mockEpicMeta = {
...convertObjectPropsToCamelCase(meta, {
deep: true,
}),
allowSubEpics: true,
};
export const mockEpicData = convertObjectPropsToCamelCase(
Object.assign({}, getJSONFixture('epic/mock_data.json'), initial, {
......
......@@ -9,6 +9,7 @@ import EpicActionsSplitButton from 'ee/related_items_tree/components/epic_action
import Icon from '~/vue_shared/components/icon.vue';
import {
mockInitialConfig,
mockParentItem,
mockQueryResponse,
} from '../../../javascripts/related_items_tree/mock_data';
......@@ -17,6 +18,7 @@ const createComponent = ({ slots } = {}) => {
const store = createDefaultStore();
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
store.dispatch('setInitialConfig', mockInitialConfig);
store.dispatch('setInitialParentItem', mockParentItem);
store.dispatch('setItemChildren', {
parentItem: mockParentItem,
......@@ -167,13 +169,42 @@ describe('RelatedItemsTree', () => {
expect(badgesContainerEl.isVisible()).toBe(true);
});
it('renders epics count and icon', () => {
const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0);
const epicIcon = epicsEl.find(Icon);
describe('when sub-epics feature is available', () => {
it('renders epics count and icon', () => {
const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0);
const epicIcon = epicsEl.find(Icon);
expect(epicsEl.text().trim()).toBe('2');
expect(epicIcon.isVisible()).toBe(true);
expect(epicIcon.props('name')).toBe('epic');
expect(epicsEl.text().trim()).toBe('2');
expect(epicIcon.isVisible()).toBe(true);
expect(epicIcon.props('name')).toBe('epic');
});
it('renders `Add an epic` dropdown button', () => {
expect(findEpicsSplitButton().isVisible()).toBe(true);
});
});
describe('when sub-epics feature is not available', () => {
beforeEach(() => {
wrapper.vm.$store.commit('SET_INITIAL_CONFIG', {
...mockInitialConfig,
allowSubEpics: false,
});
return wrapper.vm.$nextTick();
});
it('does not render epics count and icon', () => {
const countBadgesEl = wrapper.findAll('.issue-count-badge > span');
const badgeIcon = countBadgesEl.at(0).find(Icon);
expect(countBadgesEl.length).toBe(1);
expect(badgeIcon.props('name')).toBe('issues');
});
it('does not render `Add an epic` dropdown button', () => {
expect(findEpicsSplitButton().exists()).toBe(false);
});
});
it('renders issues count and icon', () => {
......@@ -185,10 +216,6 @@ describe('RelatedItemsTree', () => {
expect(issueIcon.props('name')).toBe('issues');
});
it('renders `Add an epic` dropdown button', () => {
expect(findEpicsSplitButton().isVisible()).toBe(true);
});
it('renders `Add an issue` dropdown button', () => {
const addIssueBtn = findAddIssuesButton();
......
......@@ -19,6 +19,7 @@ describe('RelatedItemsTree', () => {
issuesEndpoint: '/bar',
autoCompleteEpics: true,
autoCompleteIssues: false,
allowSubEpics: true,
};
mutations[types.SET_INITIAL_CONFIG](state, data);
......@@ -27,6 +28,7 @@ describe('RelatedItemsTree', () => {
expect(state).toHaveProperty('issuesEndpoint', '/bar');
expect(state).toHaveProperty('autoCompleteEpics', true);
expect(state).toHaveProperty('autoCompleteIssues', false);
expect(state).toHaveProperty('allowSubEpics', true);
});
});
......
......@@ -7,6 +7,7 @@ export const mockInitialConfig = {
autoCompleteEpics: true,
autoCompleteIssues: false,
userSignedIn: true,
allowSubEpics: true,
};
export const mockParentItem = {
......
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