Commit b505eb38 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '352329-related-items-tree' into 'master'

Move Roadmap App inside Epic tree

See merge request gitlab-org/gitlab!82795
parents 1cb1fc31 4b5230e3
<script>
import initRelatedItemsTree from 'ee/related_items_tree/related_items_tree_bundle';
import SidebarContext from '../sidebar_context';
import EpicBody from './epic_body.vue';
import EpicHeader from './epic_header.vue';
import EpicTabs from './epic_tabs.vue';
export default {
components: {
EpicHeader,
EpicBody,
EpicTabs,
},
mounted() {
this.sidebarContext = new SidebarContext();
initRelatedItemsTree();
},
};
</script>
......@@ -21,6 +22,5 @@ export default {
<div class="epic-page-container">
<epic-header />
<epic-body />
<epic-tabs />
</div>
</template>
<script>
import { GlButtonGroup, GlButton } from '@gitlab/ui';
import initRelatedItemsTree from 'ee/related_items_tree/related_items_tree_bundle';
const displayNoneClass = 'gl-display-none';
const containerClass = 'container-limited';
export default {
components: {
GlButton,
GlButtonGroup,
},
inject: {
allowSubEpics: {
default: false,
},
treeElementSelector: {
default: null,
},
roadmapElementSelector: {
default: null,
},
containerElementSelector: {
default: null,
},
},
data() {
return {
roadmapLoaded: false,
activeButton: this.$options.TABS.TREE,
};
},
computed: {
shouldLoadRoadmap() {
return !this.roadmapLoaded && this.allowSubEpics;
},
},
mounted() {
initRelatedItemsTree();
},
beforeMount() {
this.treeElement = document.querySelector(this.treeElementSelector);
this.roadmapElement = document.querySelector(this.roadmapElementSelector);
this.containerElement = document.querySelector(this.containerElementSelector);
},
methods: {
initRoadmap() {
return import('ee/roadmap/roadmap_bundle')
.then((roadmapBundle) => {
roadmapBundle.default();
})
.catch(() => {});
},
onTreeTabClick() {
this.activeButton = this.$options.TABS.TREE;
this.roadmapElement.classList.add(displayNoneClass);
this.treeElement.classList.remove(displayNoneClass);
this.containerElement.classList.add(containerClass);
},
showRoadmapTabContent() {
this.activeButton = this.$options.TABS.ROADMAP;
this.roadmapElement.classList.remove(displayNoneClass);
this.treeElement.classList.add(displayNoneClass);
this.containerElement.classList.remove(containerClass);
},
onRoadmapTabClick() {
if (this.shouldLoadRoadmap) {
this.initRoadmap()
.then(() => {
this.roadmapLoaded = true;
this.showRoadmapTabContent();
})
.catch(() => {});
} else {
this.showRoadmapTabContent();
}
},
},
TABS: {
TREE: 'related_items_tree',
ROADMAP: 'roadmap',
},
};
</script>
<template>
<div class="epic-tabs-holder gl-pl-0 gl-pr-0 gl-ml-0 gl-mr-0">
<div class="epic-tabs-container gl-pt-3 gl-pb-3">
<gl-button-group data-testid="tabs">
<gl-button
class="js-epic-tree-tab"
data-testid="epic-tree-tab"
:selected="activeButton === $options.TABS.TREE"
@click="onTreeTabClick"
>
{{ allowSubEpics ? __('Epics and Issues') : __('Issues') }}
</gl-button>
<gl-button
v-if="allowSubEpics"
class="js-epic-roadmap-tab"
data-testid="epic-roadmap-tab"
:selected="activeButton === $options.TABS.ROADMAP"
@click="onRoadmapTabClick"
>
{{ __('Roadmap') }}
</gl-button>
</gl-button-group>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlAlert,
},
inject: ['roadmapAppData'],
data() {
return {
loadingError: false,
roadmapLoaded: false,
};
},
computed: {
...mapState(['allowSubEpics']),
roadmapAttrs() {
if (!this.roadmapAppData) {
return {};
}
return Object.keys(this.roadmapAppData).reduce((acc, key) => {
const hypenCasedKey = key.replace(/_/g, '-');
acc[`data-${hypenCasedKey}`] = this.roadmapAppData[key];
return acc;
}, {});
},
shouldLoadRoadmap() {
return !this.roadmapLoaded && this.allowSubEpics;
},
},
mounted() {
if (this.shouldLoadRoadmap) {
this.initRoadmap();
}
},
methods: {
initRoadmap() {
return import('ee/roadmap/roadmap_bundle')
.then((roadmapBundle) => {
roadmapBundle.default();
this.roadmapLoaded = true;
})
.catch(() => {
this.loadingError = true;
});
},
},
loadingFailedText: __('Failed to load Roadmap'),
};
</script>
<template>
<div class="gl-px-3 gl-py-3 gl-bg-gray-10">
<gl-alert v-if="loadingError" variant="danger" :dismissible="false">
{{ $options.loadingFailedText }}
</gl-alert>
<div id="roadmap" class="roadmap-app border gl-rounded-base gl-bg-white">
<div id="js-roadmap" v-bind="roadmapAttrs"></div>
</div>
</div>
</template>
<script>
import { GlButtonGroup, GlButton } from '@gitlab/ui';
import { mapState } from 'vuex';
import { ITEM_TABS } from '../constants';
import ToggleLabels from '../../boards/components/toggle_labels.vue';
export default {
ITEM_TABS,
components: {
GlButtonGroup,
GlButton,
ToggleLabels,
},
props: {
activeTab: {
type: String,
required: true,
},
},
computed: {
...mapState(['allowSubEpics']),
},
};
</script>
<template>
<div class="card-header d-flex gl-px-5 gl-pt-4 gl-pt-3 flex-column flex-sm-row border-bottom-0">
<div>
<gl-button-group data-testid="buttons" class="gl-flex-grow-1 gl-display-flex">
<gl-button
class="js-epic-tree-tab"
data-testid="tree-view-button"
:selected="activeTab === $options.ITEM_TABS.TREE"
@click="() => $emit('tab-change', this.$options.ITEM_TABS.TREE)"
>
{{ __('Tree view') }}
</gl-button>
<gl-button
v-if="allowSubEpics"
class="js-epic-roadmap-tab"
data-testid="roadmap-view-button"
:selected="activeTab === $options.ITEM_TABS.ROADMAP"
@click="() => $emit('tab-change', this.$options.ITEM_TABS.ROADMAP)"
>
{{ __('Roadmap view') }}
</gl-button>
</gl-button-group>
</div>
<div class="ml-auto gl-display-none gl-sm-display-flex">
<!-- empty -->
</div>
<div
v-if="activeTab === $options.ITEM_TABS.TREE"
class="gl-sm-display-inline-flex gl-display-flex gl-mt-3 gl-sm-mt-0"
>
<toggle-labels class="gl-sm-ml-3! gl-ml-0!" />
</div>
</div>
</template>
......@@ -6,11 +6,13 @@ import { __, sprintf } from '~/locale';
import AddItemForm from '~/related_issues/components/add_issuable_form.vue';
import SlotSwitch from '~/vue_shared/components/slot_switch.vue';
import { issuableTypesMap } from '~/related_issues/constants';
import { OVERFLOW_AFTER } from '../constants';
import { ITEM_TABS, OVERFLOW_AFTER } from '../constants';
import CreateEpicForm from './create_epic_form.vue';
import CreateIssueForm from './create_issue_form.vue';
import RelatedItemsTreeBody from './related_items_tree_body.vue';
import RelatedItemsTreeHeader from './related_items_tree_header.vue';
import RelatedItemsTreeActions from './related_items_tree_actions.vue';
import RelatedItemsRoadmapApp from './related_items_roadmap_app.vue';
import TreeItemRemoveModal from './tree_item_remove_modal.vue';
const FORM_SLOTS = {
......@@ -22,11 +24,14 @@ const FORM_SLOTS = {
export default {
OVERFLOW_AFTER,
FORM_SLOTS,
ITEM_TABS,
components: {
GlLoadingIcon,
GlIcon,
RelatedItemsTreeHeader,
RelatedItemsTreeBody,
RelatedItemsTreeActions,
RelatedItemsRoadmapApp,
AddItemForm,
CreateEpicForm,
TreeItemRemoveModal,
......@@ -36,6 +41,11 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
data() {
return {
activeTab: ITEM_TABS.TREE,
};
},
computed: {
...mapState([
'parentItem',
......@@ -157,24 +167,28 @@ export default {
this.toggleCreateEpicForm({ toggleState: false });
this.setItemInputValue('');
},
handleTabChange(value) {
this.activeTab = value;
},
},
};
</script>
<template>
<div class="related-items-tree-container">
<div class="related-items-tree-container gl-mt-5">
<div v-if="itemsFetchInProgress" class="mt-2">
<gl-loading-icon size="md" />
</div>
<div
v-else
class="related-items-tree card card-slim border-top-0"
class="related-items-tree card card-slim"
:class="{
'disabled-content': disableContents,
'overflow-auto': directChildren.length > $options.OVERFLOW_AFTER,
}"
>
<related-items-tree-header :class="{ 'border-bottom-0': itemsFetchResultEmpty }" />
<slot-switch
v-if="visibleForm && parentItem.confidential"
:active-slot-names="[visibleForm]"
......@@ -240,11 +254,19 @@ export default {
/>
</template>
</slot-switch>
<related-items-tree-body
<related-items-tree-actions
v-if="!itemsFetchResultEmpty"
:active-tab="activeTab"
@tab-change="handleTabChange"
/>
<related-items-tree-body
v-if="!itemsFetchResultEmpty && activeTab === $options.ITEM_TABS.TREE"
:parent-item="parentItem"
:children="directChildren"
/>
<related-items-roadmap-app v-if="activeTab === $options.ITEM_TABS.ROADMAP" />
<tree-item-remove-modal />
</div>
</div>
......
......@@ -3,8 +3,6 @@ import { GlTooltip, GlIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { issuableTypesMap } from '~/related_issues/constants';
import ToggleLabels from '../../boards/components/toggle_labels.vue';
import EpicHealthStatus from './epic_health_status.vue';
import EpicActionsSplitButton from './epic_issue_actions_split_button.vue';
......@@ -14,7 +12,6 @@ export default {
GlIcon,
EpicHealthStatus,
EpicActionsSplitButton,
ToggleLabels,
},
computed: {
...mapState([
......@@ -73,72 +70,83 @@ export default {
</script>
<template>
<div class="card-header d-flex px-2 flex-column flex-sm-row">
<div class="d-inline-flex flex-grow-1 lh-100 align-middle mb-2 mb-sm-0">
<gl-tooltip :target="() => $refs.countBadge">
<p v-if="allowSubEpics" class="font-weight-bold m-0">
{{ __('Epics') }} &#8226;
<span class="font-weight-normal"
>{{
sprintf(__('%{openedEpics} open, %{closedEpics} closed'), {
openedEpics: descendantCounts.openedEpics,
closedEpics: descendantCounts.closedEpics,
})
}}
</span>
</p>
<p class="font-weight-bold m-0">
{{ __('Issues') }} &#8226;
<span class="font-weight-normal"
>{{
sprintf(__('%{openedIssues} open, %{closedIssues} closed'), {
openedIssues: descendantCounts.openedIssues,
closedIssues: descendantCounts.closedIssues,
})
}}
</span>
</p>
<p class="font-weight-bold m-0">
{{ __('Total weight') }} &#8226;
<span class="font-weight-normal">{{ totalWeight }} </span>
</p>
</gl-tooltip>
<div class="card-header d-flex gl-px-5 gl-py-3 flex-column flex-sm-row">
<div class="flex flex-grow-1 flex-shrink-0 gl-flex-wrap flex-column flex-sm-row">
<div class="flex flex-shrink-0 align-items-center gl-flex-wrap">
<h3 class="card-title h5 gl-my-0 flex-shrink-0">
{{ allowSubEpics ? __('Child issues and epics') : __('Issues') }}
</h3>
<div class="d-inline-flex lh-100 align-middle gl-ml-5 gl-flex-wrap">
<gl-tooltip :target="() => $refs.countBadge">
<p v-if="allowSubEpics" class="font-weight-bold m-0">
{{ __('Epics') }} &#8226;
<span class="font-weight-normal"
>{{
sprintf(__('%{openedEpics} open, %{closedEpics} closed'), {
openedEpics: descendantCounts.openedEpics,
closedEpics: descendantCounts.closedEpics,
})
}}
</span>
</p>
<p class="font-weight-bold m-0">
{{ __('Issues') }} &#8226;
<span class="font-weight-normal"
>{{
sprintf(__('%{openedIssues} open, %{closedIssues} closed'), {
openedIssues: descendantCounts.openedIssues,
closedIssues: descendantCounts.closedIssues,
})
}}
</span>
</p>
<p class="font-weight-bold m-0">
{{ __('Total weight') }} &#8226;
<span class="font-weight-normal">{{ totalWeight }} </span>
</p>
</gl-tooltip>
<div
ref="countBadge"
class="issue-count-badge gl-display-inline-flex text-secondary p-0 pr-3"
>
<span v-if="allowSubEpics" class="d-inline-flex align-items-center">
<gl-icon name="epic" class="mr-1" />
{{ totalEpicsCount }}
</span>
<span class="d-inline-flex align-items-center" :class="{ 'gl-ml-3': allowSubEpics }">
<gl-icon name="issues" class="mr-1" />
{{ totalIssuesCount }}
</span>
<span class="d-inline-flex align-items-center" :class="{ 'gl-ml-3': allowSubEpics }">
<gl-icon name="weight" class="mr-1" />
{{ totalWeight }}
</span>
</div>
</div>
</div>
<div
ref="countBadge"
class="issue-count-badge gl-display-inline-flex text-secondary p-0 pr-3"
class="gl-display-flex gl-sm-display-inline-flex lh-100 align-middle gl-sm-ml-2 gl-ml-0 gl-flex-wrap gl-mt-2 gl-sm-mt-0"
>
<span v-if="allowSubEpics" class="d-inline-flex align-items-center">
<gl-icon name="epic" class="mr-1" />
{{ totalEpicsCount }}
</span>
<span class="d-inline-flex align-items-center" :class="{ 'ml-3': allowSubEpics }">
<gl-icon name="issues" class="mr-1" />
{{ totalIssuesCount }}
</span>
<span class="d-inline-flex align-items-center" :class="{ 'ml-3': allowSubEpics }">
<gl-icon name="weight" class="mr-1" />
{{ totalWeight }}
</span>
<epic-health-status v-if="showHealthStatus" :health-status="healthStatus" />
</div>
<epic-health-status v-if="showHealthStatus" :health-status="healthStatus" />
</div>
<div class="gl-display-inline-flex gl-mr-3">
<toggle-labels />
</div>
<div
v-if="parentItem.userPermissions.adminEpic"
class="d-inline-flex flex-column flex-sm-row js-button-container"
class="gl-display-flex gl-sm-display-inline-flex gl-sm-ml-auto lh-100 align-middle gl-mt-3 gl-sm-mt-0 gl-pl-0 gl-sm-pl-7"
>
<epic-actions-split-button
:allow-sub-epics="allowSubEpics"
class="js-add-epics-issues-button qa-add-epics-button mb-2 mb-sm-0"
@showAddIssueForm="showAddIssueForm"
@showCreateIssueForm="showCreateIssueForm"
@showAddEpicForm="showAddEpicForm"
@showCreateEpicForm="showCreateEpicForm"
/>
<div
v-if="parentItem.userPermissions.adminEpic"
class="gl-flex-grow-1 flex-column flex-sm-row js-button-container"
>
<epic-actions-split-button
:allow-sub-epics="allowSubEpics"
class="js-add-epics-issues-button qa-add-epics-button w-100"
@showAddIssueForm="showAddIssueForm"
@showCreateIssueForm="showCreateIssueForm"
@showAddEpicForm="showAddEpicForm"
@showCreateEpicForm="showCreateEpicForm"
/>
</div>
</div>
</div>
</template>
......@@ -97,7 +97,7 @@ export default {
</gl-button>
<gl-loading-icon v-if="childrenFetchInProgress" class="loading-icon" size="sm" />
<tree-item-body
class="tree-item-row"
class="tree-item-row gl-mb-3"
:parent-item="parentItem"
:item="item"
:class="{
......
......@@ -313,7 +313,7 @@ export default {
<item-assignees
v-if="hasAssignees"
:assignees="item.assignees"
class="item-assignees gl-display-inline-flex gl-align-items-center gl-mr-5 mb-md-0 flex-xl-grow-0"
class="item-assignees gl-display-inline-flex gl-align-items-center gl-mr-5 gl-mb-3 flex-xl-grow-0"
/>
<epic-health-status
......
......@@ -87,7 +87,7 @@ export default {
<component
:is="treeRootWrapper"
v-bind="treeRootOptions"
class="list-unstyled related-items-list tree-root"
class="list-unstyled related-items-list tree-root gl-px-3 gl-py-3"
:move="onMove"
@start="handleDragOnStart"
@end="handleDragOnEnd"
......
......@@ -60,3 +60,8 @@ export const issueHealthStatusCSSMapping = {
};
export const trackingAddedIssue = 'g_project_management_users_epic_issue_added_from_epic';
export const ITEM_TABS = {
TREE: 'tree',
ROADMAP: 'roadmap',
};
......@@ -32,6 +32,7 @@ export default () => {
allowSubEpics,
} = el.dataset;
const initialData = JSON.parse(el.dataset.initial);
const roadmapAppData = JSON.parse(el.dataset.roadmapAppData);
Vue.component('TreeRoot', TreeRoot);
Vue.component('TreeItem', TreeItem);
......@@ -41,6 +42,9 @@ export default () => {
name: 'RelatedItemsTreeRoot',
store: createStore(),
components: { RelatedItemsTreeApp },
provide: {
roadmapAppData,
},
created() {
this.setInitialParentItem({
fullPath,
......
.related-items-tree {
border-top-left-radius: 0;
border-top-right-radius: 0;
.add-item-form-container {
border-bottom: 1px solid $border-color;
......@@ -65,6 +62,9 @@
}
.related-items-tree-body {
border-bottom-left-radius: $gl-border-radius-base;
border-bottom-right-radius: $gl-border-radius-base;
> .tree-root {
padding-top: $gl-vert-padding;
padding-bottom: 0;
......@@ -81,3 +81,24 @@
margin-bottom: $gl-vert-padding;
}
}
.related-items-tree-container {
.roadmap-app-container {
.js-roadmap-shell {
border-radius: $gl-border-radius-base;
}
.epics-list-item-empty {
display: none;
}
// This is a hacky CSS to remove the border-bottom from the
// last list in the roadmap.
.epic-item-container:nth-last-child(4) {
.epic-details-cell,
.epic-timeline-cell {
border-bottom: 0;
}
}
}
}
......@@ -37,42 +37,37 @@
'data-roadmap-element-selector' => "##{roadmapElementID}",
'data-container-element-selector' => ".#{containerClass}" }
.epic-tabs-content.js-epic-tabs-content
%div{ id: treeElementID, class: ['tab-pane', 'show', 'active'] }
.row
%section.col-md-12
#js-tree{ data: { id: @epic.to_global_id,
numerical_id: @epic.id,
iid: @epic.iid,
group_name: @group.name,
%div{ id: treeElementID, class: ['tab-pane', 'show', 'active'] }
.row
%section.col-md-12
#js-tree{ data: { id: @epic.to_global_id,
numerical_id: @epic.id,
iid: @epic.iid,
group_name: @group.name,
group_id: @group.id,
full_path: @group.full_path,
auto_complete_epics: allow_sub_epics,
auto_complete_issues: 'true',
user_signed_in: current_user.present? ? 'true' : 'false',
allow_issuable_health_status: allow_issuable_health_status,
allow_scoped_labels: allow_scoped_labels,
allow_sub_epics: allow_sub_epics,
initial: issuable_initial_data(@epic).to_json,
roadmap_app_data: sub_epics_feature_available ? { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json),
group_id: @group.id,
iid: @epic.iid,
full_path: @group.full_path,
auto_complete_epics: allow_sub_epics,
auto_complete_issues: 'true',
user_signed_in: current_user.present? ? 'true' : 'false',
allow_issuable_health_status: allow_issuable_health_status,
allow_scoped_labels: allow_scoped_labels,
allow_sub_epics: allow_sub_epics,
initial: issuable_initial_data(@epic).to_json } }
- if sub_epics_feature_available
%div{ id: roadmapElementID, class: ['tab-pane', 'gl-display-none'] }
.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_path: new_group_epic_path(@group),
list_epics_path: group_epics_path(@group),
epics_docs_path: help_page_path('user/group/epics/index'),
preset_type: roadmap_layout,
epics_state: 'all',
sorted_by: roadmap_sort_order,
inner_height: '600',
child_epics: 'true' } }
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: false,
new_epic_path: new_group_epic_path(@group),
list_epics_path: group_epics_path(@group),
epics_docs_path: help_page_path('user/group/epics/index'),
preset_type: roadmap_layout,
epics_state: 'all',
sorted_by: roadmap_sort_order,
inner_height: '600',
child_epics: true }.to_json : 'null' } }
- if related_epics_feature_available && Feature.enabled?(:related_epics_widget, @group, default_enabled: :yaml)
#js-related-epics{ data: { endpoint: group_epic_related_epic_links_path(@group, @epic),
can_add_related_epics: "#{can?(current_user, :admin_related_epic_link, @epic)}",
......
......@@ -198,10 +198,6 @@ RSpec.describe 'Epic Issues', :js do
visit group_epic_path(group, last_child)
wait_for_requests
find('.js-epic-tree-tab').click
wait_for_requests
end
it 'user cannot add new epic when hierarchy level limit has been reached' do
......
......@@ -18,14 +18,10 @@ RSpec.describe 'Related Epics', :js do
visit group_epic_path(group, epic1)
wait_for_requests
find('.js-epic-tree-tab').click
wait_for_requests
end
def open_add_epic_form
page.within('.js-epic-container .card-title') do
page.within('.related-issues-block .card-title') do
page.find('button').click
end
end
......@@ -50,9 +46,7 @@ RSpec.describe 'Related Epics', :js do
describe 'epic body section' do
it 'user can view related epics section under epic description', :aggregate_failures do
page.within('.js-epic-container') do
expect(page).to have_selector('#related-issues')
page.within('#related-issues') do
card_title = page.find('.card-title')
expect(card_title).to have_content('Linked epics')
expect(card_title).to have_link('', href: '/help/user/group/epics/linked_epics')
......
......@@ -38,7 +38,7 @@ RSpec.describe 'Epic show', :js do
button_name = type == 'issue' ? 'Add an existing issue' : 'Add an existing epic'
input_character = type == 'issue' ? '#' : '&'
page.within('.js-epic-tabs-content #tree') do
page.within('.related-items-tree-container') do
find('.js-add-epics-issues-button .dropdown-toggle').click
click_button button_name
fill_in "Paste #{type} link", with: input_character
......@@ -52,15 +52,15 @@ RSpec.describe 'Epic show', :js do
end
describe 'Epic metadata' do
it 'shows epic tabs `Epics and Issues` and `Roadmap`' do
expect(find('.js-epic-tree-tab')).to have_content('Epics and Issues')
expect(find('.js-epic-roadmap-tab')).to have_content('Roadmap')
it 'shows buttons `Tree view` and `Roadmap view`' do
expect(find('[data-testid="tree-view-button"]')).to have_content('Tree view')
expect(find('[data-testid="roadmap-view-button"]')).to have_content('Roadmap view')
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
page.within('.js-epic-container') do
expect(page).to have_selector('.related-items-tree-container')
page.within('.related-items-tree-container') do
......@@ -92,12 +92,12 @@ RSpec.describe 'Epic show', :js do
describe 'Roadmap tab' do
before do
find('.js-epic-roadmap-tab').click
find('[data-testid="roadmap-view-button"]').click
wait_for_requests
end
it 'shows Roadmap timeline with child epics', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/299298' do
page.within('.js-epic-tabs-content #roadmap') do
page.within('.related-items-tree-container #roadmap') do
expect(page).to have_selector('.roadmap-container .js-roadmap-shell')
page.within('.js-roadmap-shell .epics-list-section') do
......@@ -121,14 +121,14 @@ RSpec.describe 'Epic show', :js do
find('.js-epic-roadmap-tab').click
wait_for_all_requests # Wait for Roadmap bundle load and then Epics fetch load
page.within('.js-epic-tabs-content') do
page.within('.related-items-tree-container') do
expect(page).to have_selector('#roadmap.tab-pane', visible: true)
expect(page).to have_selector('#tree.tab-pane', visible: false)
end
find('.js-epic-tree-tab').click
page.within('.js-epic-tabs-content') do
page.within('.related-items-tree-container') do
expect(page).to have_selector('#tree.tab-pane', visible: true)
expect(page).to have_selector('#roadmap.tab-pane', visible: false)
end
......@@ -137,23 +137,22 @@ RSpec.describe 'Epic show', :js do
describe 'when the sub-epics feature is not available' do
before do
stub_licensed_features(epics: true, subepics: false)
visit group_epic_path(group, epic)
end
describe 'Epic metadata' do
it 'shows epic tab `Issues`' do
expect(find('.js-epic-tree-tab')).to have_content('Issues')
page.within('.related-items-tree-container') do
expect(find('h3.card-title')).to have_content('Issues')
end
end
end
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', text: '1')).to be_present
end
page.within('.related-items-tree-container') do
expect(page.find('.issue-count-badge', text: '1')).to be_present
end
end
end
......
import { GlTab } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import EpicTabs from 'ee/epic/components/epic_tabs.vue';
import waitForPromises from 'helpers/wait_for_promises';
const treeTabpaneID = 'tree';
const roadmapTabpaneID = 'roadmap';
const containerSelector = 'js-epic-container';
const displayNoneClass = 'gl-display-none';
const containerClass = 'container-limited';
describe('EpicTabs', () => {
let wrapper;
const createComponent = ({ provide = {} } = {}) => {
return shallowMountExtended(EpicTabs, {
provide: {
treeElementSelector: `#${treeTabpaneID}`,
roadmapElementSelector: `#${roadmapTabpaneID}`,
containerElementSelector: `.${containerSelector}`,
...provide,
},
stubs: {
GlTab,
},
});
};
const findEpicTreeTab = () => wrapper.findByTestId('epic-tree-tab');
const findEpicRoadmapTab = () => wrapper.findByTestId('epic-roadmap-tab');
afterEach(() => {
wrapper.destroy();
});
describe('default bahviour', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('displays the tabs component', () => {
expect(wrapper.findByTestId('tabs').exists()).toBe(true);
});
it('displays the tree tab', () => {
const treeTab = findEpicTreeTab();
expect(treeTab.exists()).toBe(true);
expect(treeTab.text()).toBe('Issues');
});
it('does not display the roadmap tab', () => {
expect(findEpicRoadmapTab().exists()).toBe(false);
});
});
describe('allowSubEpics = true', () => {
it('displays the correct tree tab text', () => {
wrapper = createComponent({ provide: { allowSubEpics: true } });
const treeTab = findEpicTreeTab();
expect(treeTab.exists()).toBe(true);
expect(treeTab.text()).toBe('Epics and Issues');
expect(treeTab.props().selected).toBe(true);
});
it('displays the roadmap tab', () => {
wrapper = createComponent({ provide: { allowSubEpics: true } });
const treeTab = findEpicRoadmapTab();
expect(treeTab.exists()).toBe(true);
expect(treeTab.text()).toBe('Roadmap');
expect(treeTab.props().selected).toBe(false);
});
const treeTabFixture = `
<div class="${containerSelector}">
<div id="${treeTabpaneID}" class="${displayNoneClass}"></div>
<div id="${roadmapTabpaneID}"></div>
</div>
`;
const roadmapFixture = `
<div class="${containerSelector} ${containerClass}">
<div id="${treeTabpaneID}"></div>
<div id="${roadmapTabpaneID}" class="${displayNoneClass}"></div>
</div>
`;
const treeExamples = [
['hides the roadmap tab content', `#${roadmapTabpaneID}`, false, displayNoneClass],
['displays the tree tab content', `#${treeTabpaneID}`, true, displayNoneClass],
['sets the container to limtied width', `.${containerSelector}`, false, containerClass],
];
const roadmapExamples = [
['hides the tree tab content', `#${treeTabpaneID}`, false, displayNoneClass],
['displays the roadmap tab content', `#${roadmapTabpaneID}`, true, displayNoneClass],
['removes the container width', `.${containerSelector}`, true, containerClass],
];
describe.each`
targetTab | tabTestId | fixture | examples
${treeTabpaneID} | ${'epic-tree-tab'} | ${treeTabFixture} | ${treeExamples}
${roadmapTabpaneID} | ${'epic-roadmap-tab'} | ${roadmapFixture} | ${roadmapExamples}
`('on $targetTab tab click', ({ tabTestId, fixture, examples }) => {
beforeEach(() => {
setFixtures(fixture);
wrapper = createComponent({ provide: { allowSubEpics: true } });
});
it.each(examples)('%s', async (description, tabPaneSelector, hasClassName, className) => {
const element = document.querySelector(tabPaneSelector);
expect(element.classList.contains(className)).toBe(hasClassName);
wrapper.findByTestId(tabTestId).vm.$emit('click');
await waitForPromises();
expect(element.classList.contains(className)).not.toBe(hasClassName);
});
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RelatedItemsTree RelatedItemsRoadmapApp template renders html 1`] = `
<div
class="gl-px-3 gl-py-3 gl-bg-gray-10"
>
<!---->
<div
class="roadmap-app border gl-rounded-base gl-bg-white"
id="roadmap"
>
<div
data-child-epics="true"
data-empty-state-illustration=""
data-epics-docs-path="/help/user/group/epics/index"
data-epics-path="/groups/group1/-/epics.json?parent_id=1"
data-epics-state="all"
data-full-path="group1"
data-group-id="2"
data-iid="1"
data-inner-height="600"
data-list-epics-path="/groups/group1/-/epics"
data-new-epic-path="/groups/group1/-/epics/new"
data-preset-type="MONTHS"
data-sorted-by="start_date_asc"
id="js-roadmap"
/>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RelatedItemsTree RelatedItemsTreeActions template renders button group, tree view and roadmap view buttons 1`] = `
<div
class="card-header d-flex gl-px-5 gl-pt-4 gl-pt-3 flex-column flex-sm-row border-bottom-0"
>
<div>
<gl-button-group-stub
class="gl-flex-grow-1 gl-display-flex"
data-testid="buttons"
>
<gl-button-stub
buttontextclasses=""
category="primary"
class="js-epic-tree-tab"
data-testid="tree-view-button"
icon=""
selected="true"
size="medium"
variant="default"
>
Tree view
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="primary"
class="js-epic-roadmap-tab"
data-testid="roadmap-view-button"
icon=""
size="medium"
variant="default"
>
Roadmap view
</gl-button-stub>
</gl-button-group-stub>
</div>
<div
class="ml-auto gl-display-none gl-sm-display-flex"
/>
<div
class="gl-sm-display-inline-flex gl-display-flex gl-mt-3 gl-sm-mt-0"
>
<toggle-labels-stub
class="gl-sm-ml-3! gl-ml-0!"
/>
</div>
</div>
`;
import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createDefaultStore from 'ee/related_items_tree/store';
import RelatedItemsRoadmapApp from 'ee/related_items_tree/components/related_items_roadmap_app.vue';
import { mockInitialConfig, mockRoadmapAppData } from '../mock_data';
Vue.use(Vuex);
const createComponent = ({ initialConfig = {} } = {}) => {
const store = createDefaultStore();
store.dispatch('setInitialConfig', { ...mockInitialConfig, ...initialConfig });
return shallowMountExtended(RelatedItemsRoadmapApp, {
store,
provide: {
roadmapAppData: mockRoadmapAppData,
},
});
};
describe('RelatedItemsTree', () => {
describe('RelatedItemsRoadmapApp', () => {
describe('template', () => {
let wrapper = null;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders html', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders data-* attrs', () => {
const el = wrapper.find('#js-roadmap');
const normalizedData = Object.keys(mockRoadmapAppData).reduce((acc, key) => {
const hypenCasedKey = key.replace(/_/g, '-');
acc[`data-${hypenCasedKey}`] = mockRoadmapAppData[key];
return acc;
}, {});
Object.keys(normalizedData).forEach((key) => {
expect(el.attributes()[key]).toBe(normalizedData[key]);
});
});
});
describe('initRoadmap', () => {
let wrapper = null;
let initRoadmap = null;
beforeEach(() => {
initRoadmap = jest
.spyOn(RelatedItemsRoadmapApp.methods, 'initRoadmap')
.mockReturnValue(Promise.resolve());
});
afterEach(() => {
wrapper.destroy();
});
it('does not load roadmap', () => {
wrapper = createComponent({
initialConfig: {
allowSubEpics: false,
},
});
expect(initRoadmap).not.toHaveBeenCalled();
});
it('loads roadmap', () => {
wrapper = createComponent({});
expect(initRoadmap).toHaveBeenCalled();
});
});
});
});
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ToggleLabels from 'ee/boards/components/toggle_labels.vue';
import RelatedItemsTreeActions from 'ee/related_items_tree/components/related_items_tree_actions.vue';
import { ITEM_TABS } from 'ee/related_items_tree/constants';
import createDefaultStore from 'ee/related_items_tree/store';
import { mockInitialConfig } from '../mock_data';
Vue.use(Vuex);
const createComponent = ({ slots } = {}) => {
const store = createDefaultStore();
store.dispatch('setInitialConfig', mockInitialConfig);
return shallowMountExtended(RelatedItemsTreeActions, {
store,
slots,
propsData: {
activeTab: ITEM_TABS.TREE,
},
});
};
describe('RelatedItemsTree', () => {
describe('RelatedItemsTreeActions', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders button group, tree view and roadmap view buttons', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('does not render roadmap view button when subEpics are not present', async () => {
wrapper.vm.$store.dispatch('setInitialConfig', {
...mockInitialConfig,
allowSubEpics: false,
});
await nextTick();
const roadmapViewEl = wrapper.findByTestId('roadmap-view-button');
expect(roadmapViewEl.exists()).toBe(false);
});
describe('ToggleLabels', () => {
it('renders when view is tree', () => {
expect(wrapper.find(ToggleLabels).exists()).toBe(true);
});
it('does not render when view is roadmap', async () => {
await wrapper.setProps({ activeTab: ITEM_TABS.ROADMAP });
expect(wrapper.find(ToggleLabels).exists()).toBe(false);
});
});
});
describe('emit tab-change', () => {
beforeEach(() => {
wrapper = createComponent();
});
it.each`
viewName | testid | name
${'tree view'} | ${'tree-view-button'} | ${ITEM_TABS.TREE}
${'roadmap view'} | ${'roadmap-view-button'} | ${ITEM_TABS.ROADMAP}
`('emits tab-change event when $viewName button is clicked', ({ testid, name }) => {
const button = wrapper.findByTestId(testid);
button.vm.$emit('click');
expect(wrapper.emitted('tab-change')[0]).toEqual([name]);
});
});
});
});
......@@ -10,8 +10,13 @@ import AddIssuableForm from '~/related_issues/components/add_issuable_form.vue';
import SlotSwitch from '~/vue_shared/components/slot_switch.vue';
import RelatedItemsTreeApp from 'ee/related_items_tree/components/related_items_tree_app.vue';
import RelatedItemsTreeHeader from 'ee/related_items_tree/components/related_items_tree_header.vue';
import RelatedItemsTreeActions from 'ee/related_items_tree/components/related_items_tree_actions.vue';
import RelatedItemsTreeBody from 'ee/related_items_tree/components/related_items_tree_body.vue';
import RelatedItemsRoadmapApp from 'ee/related_items_tree/components/related_items_roadmap_app.vue';
import createDefaultStore from 'ee/related_items_tree/store';
import axios from '~/lib/utils/axios_utils';
import { ITEM_TABS } from 'ee/related_items_tree/constants';
import { issuableTypesMap } from '~/related_issues/constants';
import { mockInitialConfig, mockParentItem, mockEpics, mockIssues } from '../mock_data';
......@@ -270,5 +275,38 @@ describe('RelatedItemsTreeApp', () => {
});
},
);
it('switches tab to Roadmap', async () => {
wrapper.vm.$store.state.itemsFetchResultEmpty = false;
await nextTick();
wrapper.findComponent(RelatedItemsTreeActions).vm.$emit('tab-change', ITEM_TABS.ROADMAP);
await nextTick();
expect(wrapper.vm.activeTab).toBe(ITEM_TABS.ROADMAP);
});
it.each`
visibleApp | activeTab
${'Tree View'} | ${ITEM_TABS.TREE}
${'Roadmap View'} | ${ITEM_TABS.ROADMAP}
`('renders $visibleApp when activeTab is $activeTab', async ({ activeTab }) => {
wrapper.vm.$store.state.itemsFetchResultEmpty = false;
await nextTick();
wrapper.findComponent(RelatedItemsTreeActions).vm.$emit('tab-change', activeTab);
await nextTick();
const appMapping = {
[ITEM_TABS.TREE]: RelatedItemsTreeBody,
[ITEM_TABS.ROADMAP]: RelatedItemsRoadmapApp,
};
expect(wrapper.findComponent(appMapping[activeTab]).isVisible()).toBe(true);
});
});
});
......@@ -6,7 +6,6 @@ import Vuex from 'vuex';
import EpicHealthStatus from 'ee/related_items_tree/components/epic_health_status.vue';
import EpicActionsSplitButton from 'ee/related_items_tree/components/epic_issue_actions_split_button.vue';
import RelatedItemsTreeHeader from 'ee/related_items_tree/components/related_items_tree_header.vue';
import ToggleLabels from 'ee/boards/components/toggle_labels.vue';
import createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
......@@ -59,12 +58,12 @@ describe('RelatedItemsTree', () => {
it('returns string containing epic count based on available direct children within state', () => {
expect(wrapper.findComponent(GlTooltip).text()).toContain(`Epics •
1 open, 1 closed`);
1 open, 1 closed`);
});
it('returns string containing issue count based on available direct children within state', () => {
expect(wrapper.findComponent(GlTooltip).text()).toContain(`Issues •
2 open, 1 closed`);
2 open, 1 closed`);
});
});
......@@ -78,16 +77,6 @@ describe('RelatedItemsTree', () => {
});
});
describe('toggleLabels', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('toggle labels component is visible', () => {
expect(wrapper.findComponent(ToggleLabels).isVisible()).toBe(true);
});
});
describe('epic issue actions split button', () => {
beforeEach(() => {
wrapper = createComponent();
......
......@@ -465,3 +465,19 @@ export const mockMixedFrequentlyUsedProjects = [
frequency: 3,
},
];
export const mockRoadmapAppData = {
epics_path: '/groups/group1/-/epics.json?parent_id=1',
group_id: '2',
iid: '1',
full_path: 'group1',
empty_state_illustration: '',
new_epic_path: '/groups/group1/-/epics/new',
list_epics_path: '/groups/group1/-/epics',
epics_docs_path: '/help/user/group/epics/index',
preset_type: 'MONTHS',
epics_state: 'all',
sorted_by: 'start_date_asc',
inner_height: '600',
child_epics: 'true',
};
......@@ -7402,6 +7402,9 @@ msgstr ""
msgid "Child epic doesn't exist."
msgstr ""
msgid "Child issues and epics"
msgstr ""
msgid "Chinese language support using"
msgstr ""
......@@ -14419,9 +14422,6 @@ msgstr ""
msgid "Epics Roadmap"
msgstr ""
msgid "Epics and Issues"
msgstr ""
msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
msgstr ""
......@@ -15413,6 +15413,9 @@ msgstr ""
msgid "Failed to load"
msgstr ""
msgid "Failed to load Roadmap"
msgstr ""
msgid "Failed to load assignees."
msgstr ""
......@@ -32148,6 +32151,9 @@ msgstr ""
msgid "Roadmap settings"
msgstr ""
msgid "Roadmap view"
msgstr ""
msgid "Role"
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