Commit e2e0cb4f authored by Rajat Jain's avatar Rajat Jain

Roadmap App with Tree View

Embed the roadmap app with the epic tree view

Changelog: changed
EE: true
parent ea08a31e
<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';
export default {
inject: ['roadmapAppData'],
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()
.then(() => {
this.roadmapLoaded = true;
})
.catch(() => {});
}
},
methods: {
initRoadmap() {
return import('ee/roadmap/roadmap_bundle')
.then((roadmapBundle) => {
roadmapBundle.default();
})
.catch(() => {});
},
},
};
</script>
<template>
<div class="gl-px-3 gl-py-3 gl-bg-gray-10">
<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']),
},
methods: {
onTreeTabClick() {
this.$emit('tab-change', this.$options.ITEM_TABS.TREE);
},
onRoadmapTabClick() {
this.$emit('tab-change', this.$options.ITEM_TABS.ROADMAP);
},
},
};
</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="onTreeTabClick"
>
{{ __('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="onRoadmapTabClick"
>
{{ __('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)}",
......
......@@ -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);
});
});
});
});
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.find('#roadmap').exists()).toBe(true);
expect(wrapper.find('#js-roadmap').exists()).toBe(true);
});
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', () => {
const buttonGroupEl = wrapper.findByTestId('buttons');
const treeViewEl = wrapper.findByTestId('tree-view-button');
const roadmapViewEl = wrapper.findByTestId('roadmap-view-button');
expect(buttonGroupEl.isVisible()).toBe(true);
expect(treeViewEl.isVisible()).toBe(true);
expect(roadmapViewEl.isVisible()).toBe(true);
});
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 ""
......@@ -32142,6 +32142,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