Commit 5e0cb86c authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '37081-epics-premium-final' into 'master'

Move epics to premium

Closes #37081

See merge request gitlab-org/gitlab!25184
parents 3a9007d2 a65ea302
# Epics API **(ULTIMATE)** # Epics API **(PREMIUM)**
Every API call to epic must be authenticated. Every API call to epic must be authenticated.
......
...@@ -30,6 +30,7 @@ export default { ...@@ -30,6 +30,7 @@ export default {
computed: { computed: {
...mapState([ ...mapState([
'canUpdate', 'canUpdate',
'allowSubEpics',
'sidebarCollapsed', 'sidebarCollapsed',
'participants', 'participants',
'startDateSourcingMilestoneTitle', 'startDateSourcingMilestoneTitle',
...@@ -186,7 +187,7 @@ export default { ...@@ -186,7 +187,7 @@ 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 ancestors"> <div v-if="allowSubEpics" class="block ancestors">
<ancestors-tree :ancestors="ancestors" :is-fetching="false" /> <ancestors-tree :ancestors="ancestors" :is-fetching="false" />
</div> </div>
<div class="block participants"> <div class="block participants">
......
...@@ -3,7 +3,7 @@ import { mapActions } from 'vuex'; ...@@ -3,7 +3,7 @@ import { mapActions } from 'vuex';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; 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 createStore from './store';
import EpicApp from './components/epic_app.vue'; import EpicApp from './components/epic_app.vue';
...@@ -54,7 +54,10 @@ export default (epicCreate = false) => { ...@@ -54,7 +54,10 @@ export default (epicCreate = false) => {
store, store,
components: { EpicApp }, components: { EpicApp },
created() { created() {
this.setEpicMeta(epicMeta); this.setEpicMeta({
...epicMeta,
allowSubEpics: parseBoolean(el.dataset.allowSubEpics),
});
this.setEpicData(epicData); this.setEpicData(epicData);
}, },
methods: { methods: {
......
import $ from 'jquery'; import $ from 'jquery';
import { parseBoolean } from '~/lib/utils/common_utils';
import initRelatedItemsTree from 'ee/related_items_tree/related_items_tree_bundle'; import initRelatedItemsTree from 'ee/related_items_tree/related_items_tree_bundle';
import initRoadmap from 'ee/roadmap/roadmap_bundle';
export default class EpicTabs { export default class EpicTabs {
constructor() { constructor() {
...@@ -8,12 +8,31 @@ export default class EpicTabs { ...@@ -8,12 +8,31 @@ export default class EpicTabs {
this.wrapper = document.querySelector('.content-wrapper .container-fluid:not(.breadcrumbs)'); this.wrapper = document.querySelector('.content-wrapper .container-fluid:not(.breadcrumbs)');
this.epicTabs = this.wrapper.querySelector('.js-epic-tabs-container'); this.epicTabs = this.wrapper.querySelector('.js-epic-tabs-container');
this.discussionFilterContainer = this.epicTabs.querySelector('.js-discussion-filter-container'); this.discussionFilterContainer = this.epicTabs.querySelector('.js-discussion-filter-container');
const allowSubEpics = parseBoolean(this.epicTabs.dataset.allowSubEpics);
initRelatedItemsTree(); 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() { bindEvents() {
...@@ -26,7 +45,7 @@ export default class EpicTabs { ...@@ -26,7 +45,7 @@ export default class EpicTabs {
onRoadmapShow() { onRoadmapShow() {
this.wrapper.classList.remove('container-limited'); this.wrapper.classList.remove('container-limited');
if (!this.roadmapTabLoaded) { if (!this.roadmapTabLoaded) {
initRoadmap(); this.initRoadmap();
this.roadmapTabLoaded = true; this.roadmapTabLoaded = true;
} }
} }
......
...@@ -19,6 +19,7 @@ export default () => ({ ...@@ -19,6 +19,7 @@ export default () => ({
canUpdate: false, canUpdate: false,
canDestroy: false, canDestroy: false,
canAdmin: false, canAdmin: false,
allowSubEpics: false,
// Epic Information // Epic Information
epicId: 0, epicId: 0,
......
...@@ -17,7 +17,7 @@ export default { ...@@ -17,7 +17,7 @@ export default {
EpicActionsSplitButton, EpicActionsSplitButton,
}, },
computed: { computed: {
...mapState(['parentItem', 'descendantCounts']), ...mapState(['parentItem', 'descendantCounts', 'allowSubEpics']),
totalEpicsCount() { totalEpicsCount() {
return this.descendantCounts.openedEpics + this.descendantCounts.closedEpics; return this.descendantCounts.openedEpics + this.descendantCounts.closedEpics;
}, },
...@@ -51,7 +51,7 @@ export default { ...@@ -51,7 +51,7 @@ export default {
<div class="card-header d-flex px-2"> <div class="card-header d-flex px-2">
<div class="d-inline-flex flex-grow-1 lh-100 align-middle"> <div class="d-inline-flex flex-grow-1 lh-100 align-middle">
<gl-tooltip :target="() => $refs.countBadge"> <gl-tooltip :target="() => $refs.countBadge">
<p class="font-weight-bold m-0"> <p v-if="allowSubEpics" class="font-weight-bold m-0">
{{ __('Epics') }} &#8226; {{ __('Epics') }} &#8226;
<span class="text-secondary-400 font-weight-normal" <span class="text-secondary-400 font-weight-normal"
>{{ >{{
...@@ -75,11 +75,11 @@ export default { ...@@ -75,11 +75,11 @@ export default {
</p> </p>
</gl-tooltip> </gl-tooltip>
<div ref="countBadge" class="issue-count-badge"> <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" /> <icon :size="16" name="epic" class="text-secondary mr-1" />
{{ totalEpicsCount }} {{ totalEpicsCount }}
</span> </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" /> <icon :size="16" name="issues" class="text-secondary mr-1" />
{{ totalIssuesCount }} {{ totalIssuesCount }}
</span> </span>
...@@ -88,6 +88,7 @@ export default { ...@@ -88,6 +88,7 @@ export default {
<div class="d-inline-flex js-button-container"> <div class="d-inline-flex js-button-container">
<template v-if="parentItem.userPermissions.adminEpic"> <template v-if="parentItem.userPermissions.adminEpic">
<epic-actions-split-button <epic-actions-split-button
v-if="allowSubEpics"
class="qa-add-epics-button" class="qa-add-epics-button"
@showAddEpicForm="showAddEpicForm" @showAddEpicForm="showAddEpicForm"
@showCreateEpicForm="showCreateEpicForm" @showCreateEpicForm="showCreateEpicForm"
......
...@@ -16,7 +16,15 @@ export default () => { ...@@ -16,7 +16,15 @@ export default () => {
return false; 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); const initialData = JSON.parse(el.dataset.initial);
Vue.component('tree-root', TreeRoot); Vue.component('tree-root', TreeRoot);
...@@ -46,6 +54,7 @@ export default () => { ...@@ -46,6 +54,7 @@ export default () => {
autoCompleteEpics: parseBoolean(autoCompleteEpics), autoCompleteEpics: parseBoolean(autoCompleteEpics),
autoCompleteIssues: parseBoolean(autoCompleteIssues), autoCompleteIssues: parseBoolean(autoCompleteIssues),
userSignedIn: parseBoolean(userSignedIn), userSignedIn: parseBoolean(userSignedIn),
allowSubEpics: parseBoolean(allowSubEpics),
}); });
}, },
methods: { methods: {
......
...@@ -12,6 +12,7 @@ export default { ...@@ -12,6 +12,7 @@ export default {
autoCompleteIssues, autoCompleteIssues,
projectsEndpoint, projectsEndpoint,
userSignedIn, userSignedIn,
allowSubEpics,
}, },
) { ) {
state.epicsEndpoint = epicsEndpoint; state.epicsEndpoint = epicsEndpoint;
...@@ -20,6 +21,7 @@ export default { ...@@ -20,6 +21,7 @@ export default {
state.autoCompleteIssues = autoCompleteIssues; state.autoCompleteIssues = autoCompleteIssues;
state.projectsEndpoint = projectsEndpoint; state.projectsEndpoint = projectsEndpoint;
state.userSignedIn = userSignedIn; state.userSignedIn = userSignedIn;
state.allowSubEpics = allowSubEpics;
}, },
[types.SET_INITIAL_PARENT_ITEM](state, data) { [types.SET_INITIAL_PARENT_ITEM](state, data) {
......
...@@ -36,6 +36,7 @@ export default () => ({ ...@@ -36,6 +36,7 @@ export default () => ({
showCreateEpicForm: false, showCreateEpicForm: false,
autoCompleteEpics: false, autoCompleteEpics: false,
autoCompleteIssues: false, autoCompleteIssues: false,
allowSubEpics: false,
removeItemModalProps: { removeItemModalProps: {
parentItem: {}, parentItem: {},
item: {}, item: {},
......
...@@ -3,8 +3,8 @@ ...@@ -3,8 +3,8 @@
class Groups::EpicLinksController < Groups::ApplicationController class Groups::EpicLinksController < Groups::ApplicationController
include EpicRelations include EpicRelations
before_action :check_epics_available!, only: :index before_action :check_epics_available!, only: [:index, :destroy]
before_action :check_subepics_available!, only: [:create, :destroy, :update] before_action :check_subepics_available!, only: [:create, :update]
def update def update
result = EpicLinks::UpdateService.new(child_epic, current_user, params[:epic]).execute result = EpicLinks::UpdateService.new(child_epic, current_user, params[:epic]).execute
......
...@@ -66,6 +66,7 @@ class License < ApplicationRecord ...@@ -66,6 +66,7 @@ class License < ApplicationRecord
design_management design_management
disable_name_update_for_users disable_name_update_for_users
email_additional_text email_additional_text
epics
extended_audit_events extended_audit_events
external_authorization_service_api_management external_authorization_service_api_management
feature_flags feature_flags
...@@ -111,7 +112,6 @@ class License < ApplicationRecord ...@@ -111,7 +112,6 @@ class License < ApplicationRecord
credentials_inventory credentials_inventory
dast dast
dependency_scanning dependency_scanning
epics
group_ip_restriction group_ip_restriction
group_level_compliance_dashboard group_level_compliance_dashboard
incident_management incident_management
......
...@@ -30,6 +30,6 @@ class LinkedEpicEntity < Grape::Entity ...@@ -30,6 +30,6 @@ class LinkedEpicEntity < Grape::Entity
end end
expose :can_admin do |epic| expose :can_admin do |epic|
can?(request.current_user, :admin_epic, epic) can?(request.current_user, :admin_epic_link, epic)
end end
end end
...@@ -26,8 +26,8 @@ module EpicLinks ...@@ -26,8 +26,8 @@ module EpicLinks
def permission_to_remove_relation? def permission_to_remove_relation?
child_epic.present? && child_epic.present? &&
parent_epic.present? && parent_epic.present? &&
can?(current_user, :admin_epic_link, parent_epic) && can?(current_user, :admin_epic, parent_epic) &&
can?(current_user, :admin_epic_link, child_epic) can?(current_user, :admin_epic, child_epic)
end end
def not_found_message def not_found_message
......
...@@ -3,6 +3,9 @@ ...@@ -3,6 +3,9 @@
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- epic_reference = @epic.to_reference - 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) - add_to_breadcrumbs _("Epics"), group_epics_path(@group)
- breadcrumb_title epic_reference - breadcrumb_title epic_reference
...@@ -11,17 +14,22 @@ ...@@ -11,17 +14,22 @@
- page_card_attributes @epic.card_attributes - 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-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 %ul.epic-tabs.nav-tabs.nav.nav-links.scrolling-tabs
%li.tree-tab %li.tree-tab
%a#tree-tab.active{ href: '#tree', data: { toggle: 'tab' } } %a#tree-tab.active{ href: '#tree', data: { toggle: 'tab' } }
= _('Epics and Issues') - if sub_epics_feature_available
%li.roadmap-tab = _('Epics and Issues')
%a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } } - else
= _('Roadmap') = _('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 .tab-content.epic-tabs-content.js-epic-tabs-content
#tree.tab-pane.show.active #tree.tab-pane.show.active
...@@ -33,22 +41,24 @@ ...@@ -33,22 +41,24 @@
auto_complete_epics: 'true', auto_complete_epics: 'true',
auto_complete_issues: 'true', auto_complete_issues: 'true',
user_signed_in: current_user.present? ? 'true' : 'false', user_signed_in: current_user.present? ? 'true' : 'false',
allow_sub_epics: allow_sub_epics,
initial: issuable_initial_data(@epic).to_json } } initial: issuable_initial_data(@epic).to_json } }
#roadmap.tab-pane - if sub_epics_feature_available
.row #roadmap.tab-pane
%section.col-md-12 .row
#js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json), %section.col-md-12
group_id: @group.id, #js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json),
iid: @epic.iid, group_id: @group.id,
full_path: @group.full_path, iid: @epic.iid,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), full_path: @group.full_path,
has_filters_applied: 'false', empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
new_epic_endpoint: group_epics_path(@group), has_filters_applied: 'false',
preset_type: roadmap_layout, new_epic_endpoint: group_epics_path(@group),
epics_state: 'all', preset_type: roadmap_layout,
sorted_by: roadmap_sort_order, epics_state: 'all',
inner_height: '600', sorted_by: roadmap_sort_order,
child_epics: 'true' } } inner_height: '600',
child_epics: 'true' } }
%hr.epic-discussion-separator.mt-1.mb-0 %hr.epic-discussion-separator.mt-1.mb-0
.d-flex.justify-content-between.content-block.content-block-small.emoji-list-container.js-noteable-awards .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 = render 'award_emoji/awards_block', awardable: @epic, inline: true
......
---
title: Add single-level Epics to EE Premium
merge_request: 25184
author:
type: added
...@@ -176,6 +176,8 @@ describe Groups::EpicLinksController do ...@@ -176,6 +176,8 @@ describe Groups::EpicLinksController do
epic1.update(parent: parent_epic) epic1.update(parent: parent_epic)
end end
let(:features_when_forbidden) { { epics: false } }
subject { delete :destroy, params: { group_id: group, epic_id: parent_epic.to_param, id: epic1.id } } subject { delete :destroy, params: { group_id: group, epic_id: parent_epic.to_param, id: epic1.id } }
it_behaves_like 'unlicensed subepics action' it_behaves_like 'unlicensed subepics action'
......
...@@ -98,108 +98,135 @@ describe 'Epic Issues', :js do ...@@ -98,108 +98,135 @@ describe 'Epic Issues', :js do
visit_epic visit_epic
end end
it 'user can display create new epic form by clicking the dropdown item' do context 'handling epics' do
expect(page).not_to have_selector('input[placeholder="New epic title"]') it 'user can display create new epic form by clicking the dropdown item' do
expect(page).not_to have_selector('input[placeholder="New epic title"]')
find('.related-items-tree-container .js-add-epics-button .dropdown-toggle').click find('.related-items-tree-container .js-add-epics-button .dropdown-toggle').click
find('.related-items-tree-container .js-add-epics-button .dropdown-item', text: 'Create new epic').click find('.related-items-tree-container .js-add-epics-button .dropdown-item', text: 'Create new epic').click
expect(page).to have_selector('input[placeholder="New epic title"]') expect(page).to have_selector('input[placeholder="New epic title"]')
end
end end
it 'user can see all issues of the group and delete the associations' do context 'handling epic issues' do
within('.related-items-tree-container ul.related-items-list') do it 'user can see all issues of the group and delete the associations' do
expect(page).to have_selector('li.js-item-type-issue', count: 2) within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_content(public_issue.title) expect(page).to have_selector('li.js-item-type-issue', count: 2)
expect(page).to have_content(private_issue.title) expect(page).to have_content(public_issue.title)
expect(page).to have_content(private_issue.title)
first('li.js-item-type-issue button.js-issue-item-remove-button').click first('li.js-item-type-issue button.js-issue-item-remove-button').click
end
first('#item-remove-confirmation .modal-footer .btn-danger').click
wait_for_requests
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 1)
end
end end
first('#item-remove-confirmation .modal-footer .btn-danger').click
wait_for_requests it 'user cannot add new issues to the epic from another group' do
add_issues("#{issue_invalid.to_reference(full: true)}")
within('.related-items-tree-container ul.related-items-list') do expect(page).to have_selector('.gl-field-error')
expect(page).to have_selector('li.js-item-type-issue', count: 1) expect(find('.gl-field-error')).to have_text("Issue cannot be found.")
end end
end
it 'user can see all epics of the group and delete the associations' do it 'user can add new issues to the epic' do
within('.related-items-tree-container ul.related-items-list') do references = "#{issue_to_add.to_reference(full: true)}"
expect(page).to have_selector('li.js-item-type-epic', count: 2)
expect(page).to have_content(nested_epics[0].title) add_issues(references)
expect(page).to have_content(nested_epics[1].title)
expect(page).not_to have_selector('.gl-field-error')
expect(page).not_to have_content("Issue cannot be found.")
first('li.js-item-type-epic button.js-issue-item-remove-button').click within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 3)
end
end end
first('#item-remove-confirmation .modal-footer .btn-danger').click
wait_for_requests it 'user cannot add new issue that does not exist' do
add_issues("&123")
within('.related-items-tree-container ul.related-items-list') do expect(page).to have_selector('.gl-field-error')
expect(page).to have_selector('li.js-item-type-epic', count: 1) expect(find('.gl-field-error')).to have_text("Issue cannot be found.")
end end
end end
it 'user cannot add new issues to the epic from another group' do context 'handling epic links' do
add_issues("#{issue_invalid.to_reference(full: true)}") context 'when subepics feature is enabled' do
it 'user can see all epics of the group and delete the associations' do
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 2)
expect(page).to have_content(nested_epics[0].title)
expect(page).to have_content(nested_epics[1].title)
expect(page).to have_selector('.gl-field-error') first('li.js-item-type-epic button.js-issue-item-remove-button').click
expect(find('.gl-field-error')).to have_text("Issue cannot be found.") end
end first('#item-remove-confirmation .modal-footer .btn-danger').click
it 'user can add new issues to the epic' do wait_for_requests
references = "#{issue_to_add.to_reference(full: true)}"
add_issues(references) within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 1)
end
end
expect(page).not_to have_selector('.gl-field-error') it 'user cannot add new epic that does not exist' do
expect(page).not_to have_content("Issue cannot be found.") add_epics("&123")
within('.related-items-tree-container ul.related-items-list') do expect(page).to have_selector('.gl-field-error')
expect(page).to have_selector('li.js-item-type-issue', count: 3) expect(find('.gl-field-error')).to have_text("Epic cannot be found.")
end end
end
it 'user cannot add new issue that does not exist' do it 'user can add new epics to the epic' do
add_issues("&123") references = "#{epic_to_add.to_reference(full: true)}"
add_epics(references)
expect(page).to have_selector('.gl-field-error') expect(page).not_to have_selector('.gl-field-error')
expect(find('.gl-field-error')).to have_text("Issue cannot be found.") expect(page).not_to have_content("Epic cannot be found.")
end
it 'user cannot add new epic that does not exist' do within('.related-items-tree-container ul.related-items-list') do
add_epics("&123") expect(page).to have_selector('li.js-item-type-epic', count: 3)
end
end
expect(page).to have_selector('.gl-field-error') context 'when epics are nested too deep' do
expect(find('.gl-field-error')).to have_text("Epic cannot be found.") let(:epic1) { create(:epic, group: group, parent_id: epic.id) }
end let(:epic2) { create(:epic, group: group, parent_id: epic1.id) }
let(:epic3) { create(:epic, group: group, parent_id: epic2.id) }
let(:epic4) { create(:epic, group: group, parent_id: epic3.id) }
context 'when epics are nested too deep' do before do
let(:epic1) { create(:epic, group: group, parent_id: epic.id) } visit group_epic_path(group, epic4)
let(:epic2) { create(:epic, group: group, parent_id: epic1.id) }
let(:epic3) { create(:epic, group: group, parent_id: epic2.id) }
let(:epic4) { create(:epic, group: group, parent_id: epic3.id) }
before do wait_for_requests
stub_licensed_features(epics: true, subepics: true)
sign_in(user) find('.js-epic-tabs-container #tree-tab').click
visit group_epic_path(group, epic4)
wait_for_requests wait_for_requests
end
find('.js-epic-tabs-container #tree-tab').click it 'user cannot add new epic when hierarchy level limit has been reached' do
references = "#{epic_to_add.to_reference(full: true)}"
add_epics(references)
wait_for_requests expect(page).to have_selector('.gl-field-error')
expect(find('.gl-field-error')).to have_text("This epic can't be added because the parent is already at the maximum depth from its most distant ancestor")
end
end
end end
it 'user cannot add new epic when hierarchy level limit has been reached' do context 'when subepics feature is disabled' do
references = "#{epic_to_add.to_reference(full: true)}" it 'user can not add new epics to the epic' do
add_epics(references) stub_licensed_features(epics: true, subepics: false)
expect(page).to have_selector('.gl-field-error') visit_epic
expect(find('.gl-field-error')).to have_text("This epic can't be added because the parent is already at the maximum depth from its most distant ancestor")
expect(page).not_to have_selector('.related-items-tree-container .js-add-epics-button')
end
end end
end end
...@@ -222,17 +249,5 @@ describe 'Epic Issues', :js do ...@@ -222,17 +249,5 @@ describe 'Epic Issues', :js do
end end
end end
end end
it 'user can add new epics to the epic' do
references = "#{epic_to_add.to_reference(full: true)}"
add_epics(references)
expect(page).not_to have_selector('.gl-field-error')
expect(page).not_to have_content("Epic cannot be found.")
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 3)
end
end
end end
end end
...@@ -5,7 +5,9 @@ require 'spec_helper' ...@@ -5,7 +5,9 @@ require 'spec_helper'
describe 'Epic show', :js do describe 'Epic show', :js do
let(:user) { create(:user, name: 'Rick Sanchez', username: 'rick.sanchez') } let(:user) { create(:user, name: 'Rick Sanchez', username: 'rick.sanchez') }
let(:group) { create(:group, :public) } let(:group) { create(:group, :public) }
let(:public_project) { create(:project, :public, group: group) }
let(:label) { create(:group_label, group: group, title: 'bug') } 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(:note_text) { 'Contemnit enim disserendi elegantiam.' }
let(:epic_title) { 'Sample epic' } let(:epic_title) { 'Sample epic' }
...@@ -22,83 +24,116 @@ describe 'Epic show', :js do ...@@ -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!(: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_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_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 before do
group.add_developer(user) group.add_developer(user)
stub_licensed_features(epics: true) stub_licensed_features(epics: true, subepics: true)
sign_in(user) sign_in(user)
visit group_epic_path(group, epic) visit group_epic_path(group, epic)
end end
describe 'Epic metadata' do describe 'when sub-epics feature is available' do
it 'shows epic status, date and author in header' do describe 'Epic metadata' do
page.within('.epic-page-container .detail-page-header-body') do it 'shows epic tabs `Epics and Issues` and `Roadmap`' do
expect(find('.issuable-status-box > span')).to have_content('Open') page.within('.js-epic-tabs-container') do
expect(find('.issuable-meta')).to have_content('Opened just now by') expect(find('.epic-tabs #tree-tab')).to have_content('Epics and Issues')
expect(find('.issuable-meta .js-user-avatar-link-username')).to have_content('Rick Sanchez') expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap')
end
end end
end end
it 'shows epic title and description' do describe 'Epics and Issues tab' do
page.within('.epic-page-container .detail-page-description') do it 'shows Related items tree with child epics' do
expect(find('.title-container .title')).to have_content(epic_title) page.within('.js-epic-tabs-content #tree') do
expect(find('.description .md')).to have_content(markdown.squish) expect(page).to have_selector('.related-items-tree-container')
end
end
it 'shows epic tabs' do page.within('.related-items-tree-container') do
page.within('.js-epic-tabs-container') do expect(page.find('.issue-count-badge')).to have_content('2')
expect(find('.epic-tabs #tree-tab')).to have_content('Epics and Issues') expect(find('.tree-item:nth-child(1) .sortable-link')).to have_content('Child epic B')
expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap') expect(find('.tree-item:nth-child(2) .sortable-link')).to have_content('Child epic A')
end
end
end end
end end
it 'shows epic thread filter dropdown' do describe 'Roadmap tab' do
page.within('.js-noteable-awards') do before do
expect(find('.js-discussion-filter-container #discussion-filter-dropdown')).to have_content('Show all activity') find('.js-epic-tabs-container #roadmap-tab').click
wait_for_requests
end end
end
end
describe 'Epics and Issues tab' do it 'shows Roadmap timeline with child epics' do
it 'shows Related items tree with child epics' do page.within('.js-epic-tabs-content #roadmap') do
page.within('.js-epic-tabs-content #tree') do expect(page).to have_selector('.roadmap-container .roadmap-shell')
expect(page).to have_selector('.related-items-tree-container')
page.within('.related-items-tree-container') do page.within('.roadmap-shell .epics-list-section') do
expect(page.find('.issue-count-badge')).to have_content('2') expect(page).not_to have_content(not_child.title)
expect(find('.tree-item:nth-child(1) .sortable-link')).to have_content('Child epic B') expect(find('.epics-list-item:nth-child(1) .epic-title a')).to have_content('Child epic B')
expect(find('.tree-item:nth-child(2) .sortable-link')).to have_content('Child epic A') expect(find('.epics-list-item:nth-child(2) .epic-title a')).to have_content('Child epic A')
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)
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
end end
describe 'Roadmap tab' do describe 'when sub-epics feature not is available' do
before do before do
find('.js-epic-tabs-container #roadmap-tab').click stub_licensed_features(epics: true, subepics: false)
wait_for_requests
visit group_epic_path(group, epic)
end end
it 'shows Roadmap timeline with child epics' do describe 'Epic metadata' do
page.within('.js-epic-tabs-content #roadmap') do it 'shows epic tab `Issues`' do
expect(page).to have_selector('.roadmap-container .roadmap-shell') 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 describe 'Issues tab' do
expect(page).not_to have_content(not_child.title) it 'shows Related items tree with child epics' do
expect(find('.epics-list-item:nth-child(1) .epic-title a')).to have_content('Child epic B') page.within('.js-epic-tabs-content #tree') do
expect(find('.epics-list-item:nth-child(2) .epic-title a')).to have_content('Child epic A') 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
end end
end
it 'does not show thread filter dropdown' do describe 'Epic metadata' do
expect(find('.js-noteable-awards')).to have_selector('.js-discussion-filter-container', visible: false) 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 end
it 'has no limit on container width' do it 'shows epic title and description' do
expect(find('.content-wrapper .container-fluid:not(.breadcrumbs)')[:class]).not_to include('container-limited') 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 end
end end
...@@ -203,31 +203,50 @@ describe('EpicSidebarComponent', () => { ...@@ -203,31 +203,50 @@ describe('EpicSidebarComponent', () => {
expect(vm.$el.querySelector('.js-labels-block')).not.toBeNull(); expect(vm.$el.querySelector('.js-labels-block')).not.toBeNull();
}); });
it('renders ancestors list', done => { describe('when sub-epics feature is available', () => {
store.dispatch('toggleSidebarFlag', false); 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() describe('when sub-epics feature is not available', () => {
.then(() => { it('does not render ancestors list', done => {
const ancestorsEl = vm.$el.querySelector('.block.ancestors'); 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( expect(getEls('li.vertical-timeline-row').length).toBe(reverseAncestors.length);
reverseAncestors.map(a => a.title),
);
expect(getEls('li.vertical-timeline-row a').map(a => a.getAttribute('href'))).toEqual( expect(getEls('a').map(el => el.innerText.trim())).toEqual(
reverseAncestors.map(a => a.url), reverseAncestors.map(a => a.title),
); );
})
.then(done) expect(getEls('li.vertical-timeline-row a').map(a => a.getAttribute('href'))).toEqual(
.catch(done.fail); reverseAncestors.map(a => a.url),
);
})
.then(done)
.catch(done.fail);
});
}); });
it('renders participants list element', () => { it('renders participants list element', () => {
......
...@@ -5,9 +5,12 @@ const metaFixture = getJSONFixture('epic/mock_meta.json'); ...@@ -5,9 +5,12 @@ const metaFixture = getJSONFixture('epic/mock_meta.json');
const meta = JSON.parse(metaFixture.meta); const meta = JSON.parse(metaFixture.meta);
const initial = JSON.parse(metaFixture.initial); const initial = JSON.parse(metaFixture.initial);
export const mockEpicMeta = convertObjectPropsToCamelCase(meta, { export const mockEpicMeta = {
deep: true, ...convertObjectPropsToCamelCase(meta, {
}); deep: true,
}),
allowSubEpics: true,
};
export const mockEpicData = convertObjectPropsToCamelCase( export const mockEpicData = convertObjectPropsToCamelCase(
Object.assign({}, getJSONFixture('epic/mock_data.json'), initial, { Object.assign({}, getJSONFixture('epic/mock_data.json'), initial, {
......
...@@ -9,6 +9,7 @@ import EpicActionsSplitButton from 'ee/related_items_tree/components/epic_action ...@@ -9,6 +9,7 @@ import EpicActionsSplitButton from 'ee/related_items_tree/components/epic_action
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { import {
mockInitialConfig,
mockParentItem, mockParentItem,
mockQueryResponse, mockQueryResponse,
} from '../../../javascripts/related_items_tree/mock_data'; } from '../../../javascripts/related_items_tree/mock_data';
...@@ -17,6 +18,7 @@ const createComponent = ({ slots } = {}) => { ...@@ -17,6 +18,7 @@ const createComponent = ({ slots } = {}) => {
const store = createDefaultStore(); const store = createDefaultStore();
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group); const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
store.dispatch('setInitialConfig', mockInitialConfig);
store.dispatch('setInitialParentItem', mockParentItem); store.dispatch('setInitialParentItem', mockParentItem);
store.dispatch('setItemChildren', { store.dispatch('setItemChildren', {
parentItem: mockParentItem, parentItem: mockParentItem,
...@@ -167,13 +169,42 @@ describe('RelatedItemsTree', () => { ...@@ -167,13 +169,42 @@ describe('RelatedItemsTree', () => {
expect(badgesContainerEl.isVisible()).toBe(true); expect(badgesContainerEl.isVisible()).toBe(true);
}); });
it('renders epics count and icon', () => { describe('when sub-epics feature is available', () => {
const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0); it('renders epics count and icon', () => {
const epicIcon = epicsEl.find(Icon); const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0);
const epicIcon = epicsEl.find(Icon);
expect(epicsEl.text().trim()).toBe('2'); expect(epicsEl.text().trim()).toBe('2');
expect(epicIcon.isVisible()).toBe(true); expect(epicIcon.isVisible()).toBe(true);
expect(epicIcon.props('name')).toBe('epic'); 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', () => { it('renders issues count and icon', () => {
...@@ -185,10 +216,6 @@ describe('RelatedItemsTree', () => { ...@@ -185,10 +216,6 @@ describe('RelatedItemsTree', () => {
expect(issueIcon.props('name')).toBe('issues'); 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', () => { it('renders `Add an issue` dropdown button', () => {
const addIssueBtn = findAddIssuesButton(); const addIssueBtn = findAddIssuesButton();
......
...@@ -19,6 +19,7 @@ describe('RelatedItemsTree', () => { ...@@ -19,6 +19,7 @@ describe('RelatedItemsTree', () => {
issuesEndpoint: '/bar', issuesEndpoint: '/bar',
autoCompleteEpics: true, autoCompleteEpics: true,
autoCompleteIssues: false, autoCompleteIssues: false,
allowSubEpics: true,
}; };
mutations[types.SET_INITIAL_CONFIG](state, data); mutations[types.SET_INITIAL_CONFIG](state, data);
...@@ -27,6 +28,7 @@ describe('RelatedItemsTree', () => { ...@@ -27,6 +28,7 @@ describe('RelatedItemsTree', () => {
expect(state).toHaveProperty('issuesEndpoint', '/bar'); expect(state).toHaveProperty('issuesEndpoint', '/bar');
expect(state).toHaveProperty('autoCompleteEpics', true); expect(state).toHaveProperty('autoCompleteEpics', true);
expect(state).toHaveProperty('autoCompleteIssues', false); expect(state).toHaveProperty('autoCompleteIssues', false);
expect(state).toHaveProperty('allowSubEpics', true);
}); });
}); });
......
...@@ -7,6 +7,7 @@ export const mockInitialConfig = { ...@@ -7,6 +7,7 @@ export const mockInitialConfig = {
autoCompleteEpics: true, autoCompleteEpics: true,
autoCompleteIssues: false, autoCompleteIssues: false,
userSignedIn: true, userSignedIn: true,
allowSubEpics: true,
}; };
export const mockParentItem = { export const mockParentItem = {
......
...@@ -1451,7 +1451,8 @@ describe Project do ...@@ -1451,7 +1451,8 @@ describe Project do
before do before do
allow(License).to receive(:current).and_return(global_license) allow(License).to receive(:current).and_return(global_license)
allow(global_license).to receive(:features).and_return([ allow(global_license).to receive(:features).and_return([
:epics, # Gold only :subepics, # Gold only
:epics, # Silver and up
:service_desk, # Silver and up :service_desk, # Silver and up
:audit_events, # Bronze and up :audit_events, # Bronze and up
:geo # Global feature, should not be checked at namespace level :geo # Global feature, should not be checked at namespace level
...@@ -1477,7 +1478,7 @@ describe Project do ...@@ -1477,7 +1478,7 @@ describe Project do
let(:plan_license) { :silver } let(:plan_license) { :silver }
it 'filters for silver features' do it 'filters for silver features' do
is_expected.to contain_exactly(:service_desk, :audit_events, :geo) is_expected.to contain_exactly(:service_desk, :audit_events, :geo, :epics)
end end
end end
...@@ -1485,7 +1486,7 @@ describe Project do ...@@ -1485,7 +1486,7 @@ describe Project do
let(:plan_license) { :gold } let(:plan_license) { :gold }
it 'filters for gold features' do it 'filters for gold features' do
is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo) is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo, :subepics)
end end
end end
...@@ -1502,7 +1503,7 @@ describe Project do ...@@ -1502,7 +1503,7 @@ describe Project do
let(:project) { create(:project, :public, group: group) } let(:project) { create(:project, :public, group: group) }
it 'includes all features in global license' do it 'includes all features in global license' do
is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo) is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo, :subepics)
end end
end end
end end
...@@ -1510,7 +1511,7 @@ describe Project do ...@@ -1510,7 +1511,7 @@ describe Project do
context 'when namespace should not be checked' do context 'when namespace should not be checked' do
it 'includes all features in global license' do it 'includes all features in global license' do
is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo) is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo, :subepics)
end end
end end
......
...@@ -45,9 +45,9 @@ describe EpicLinks::DestroyService do ...@@ -45,9 +45,9 @@ describe EpicLinks::DestroyService do
described_class.new(child_epic, user).execute described_class.new(child_epic, user).execute
end end
context 'when subepics feature is disabled' do context 'when epics feature is disabled' do
before do before do
stub_licensed_features(epics: true, subepics: false) stub_licensed_features(epics: false)
end end
subject { remove_epic_relation(child_epic) } subject { remove_epic_relation(child_epic) }
...@@ -55,9 +55,9 @@ describe EpicLinks::DestroyService do ...@@ -55,9 +55,9 @@ describe EpicLinks::DestroyService do
include_examples 'returns not found error' include_examples 'returns not found error'
end end
context 'when subepics feature is enabled' do context 'when epics feature is enabled' do
before do before do
stub_licensed_features(epics: true, subepics: true) stub_licensed_features(epics: true)
end end
context 'when the user has no permissions to remove epic relation' do context 'when the user has no permissions to remove epic relation' do
......
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