Commit c04dd69f authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '300641-refactors-epics-select-sidebar-component-to-vue-apollo' into 'master'

Epic sidebar widget using Apollo [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!62168
parents 5139aaac 0e4094dd
......@@ -24,12 +24,12 @@ export default {
BoardSidebarDueDate,
SidebarSubscriptionsWidget,
BoardSidebarMilestoneSelect,
BoardSidebarEpicSelect: () =>
import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'),
BoardSidebarWeightInput: () =>
import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'),
SidebarIterationWidget: () =>
import('ee_component/sidebar/components/sidebar_iteration_widget.vue'),
SidebarDropdownWidget: () =>
import('ee_component/sidebar/components/sidebar_dropdown_widget.vue'),
},
inject: {
multipleAssigneesFeatureAvailable: {
......@@ -89,7 +89,15 @@ export default {
:allow-multiple-assignees="multipleAssigneesFeatureAvailable"
@assignees-updated="setAssignees"
/>
<board-sidebar-epic-select v-if="epicFeatureAvailable" class="epic" />
<sidebar-dropdown-widget
v-if="epicFeatureAvailable"
:iid="activeBoardItem.iid"
issuable-attribute="epic"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
data-testid="sidebar-epic"
/>
<div>
<board-sidebar-milestone-select />
<sidebar-iteration-widget
......
......@@ -30,7 +30,8 @@
.block.reviewer.qa-reviewer-block
= render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers, signed_in: signed_in
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar
- if @project.group.present?
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if issuable_sidebar[:supports_milestone]
- milestone = issuable_sidebar[:milestone] || {}
......
<script>
import { GlLink } from '@gitlab/ui';
import { mapState, mapGetters, mapActions } from 'vuex';
import EpicsSelect from 'ee/vue_shared/components/sidebar/epics_select/base.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import { fullEpicId } from '../../boards_util';
export default {
components: {
BoardEditableItem,
EpicsSelect,
GlLink,
},
i18n: {
epic: __('Epic'),
updateEpicError: s__(
'IssueBoards|An error occurred while assigning the selected epic to the issue.',
),
fetchEpicError: s__(
'IssueBoards|An error occurred while fetching the assigned epic of the selected issue.',
),
},
inject: ['groupId'],
computed: {
...mapState(['epics', 'epicsCacheById', 'epicFetchInProgress']),
...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']),
epic() {
return this.activeBoardItem.epic;
},
epicData() {
const hasEpic = this.epic !== null;
const epicFetched = !this.epicFetchInProgress;
return hasEpic && epicFetched ? this.epicsCacheById[this.epic.id] : {};
},
initialEpic() {
return this.epic
? {
...this.epicData,
id: getIdFromGraphQLId(this.epic.id),
}
: {};
},
},
watch: {
epic: {
deep: true,
immediate: true,
async handler() {
if (this.epic) {
try {
await this.fetchEpicForActiveIssue();
} catch (e) {
createFlash({
message: this.$options.i18n.fetchEpicError,
error: e,
captureError: true,
});
}
}
},
},
},
methods: {
...mapActions(['setActiveIssueEpic', 'fetchEpicForActiveIssue']),
handleOpen() {
if (!this.epicFetchInProgress) {
this.$refs.epicSelect.toggleFormDropdown();
} else {
this.$refs.sidebarItem.collapse();
}
},
handleClose() {
this.$refs.sidebarItem.collapse();
this.$refs.epicSelect.toggleFormDropdown();
},
async setEpic(selectedEpic) {
this.handleClose();
const epicId = selectedEpic?.id ? fullEpicId(selectedEpic.id) : null;
const assignedEpicId = this.epic?.id ? fullEpicId(this.epic.id) : null;
if (epicId === assignedEpicId) {
return;
}
try {
await this.setActiveIssueEpic(epicId);
} catch (e) {
createFlash({ message: this.$options.i18n.updateEpicError, error: e, captureError: true });
}
},
},
};
</script>
<template>
<board-editable-item
ref="sidebarItem"
:title="$options.i18n.epic"
:loading="epicFetchInProgress"
data-testid="sidebar-epic"
@open="handleOpen"
@close="handleClose"
>
<template v-if="epicData.title" #collapsed>
<gl-link class="gl-text-gray-900! gl-font-weight-bold" :href="epicData.webPath">
{{ epicData.title }}
</gl-link>
</template>
<epics-select
v-if="!epicFetchInProgress"
ref="epicSelect"
class="gl-w-full"
:group-id="groupId"
:can-edit="true"
:initial-epic="initialEpic"
:initial-epic-loading="false"
variant="standalone"
:show-header="false"
@epicSelect="setEpic"
@hide="handleClose"
/>
</board-editable-item>
</template>
<script>
import {
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
GlDropdownDivider,
GlLoadingIcon,
GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import { __, s__, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import {
IssuableAttributeState,
IssuableAttributeType,
issuableAttributesQueries,
noAttributeId,
} from '../constants';
export default {
noAttributeId,
i18n: {
[IssuableAttributeType.Iteration]: __('Iteration'),
[IssuableAttributeType.Epic]: __('Epic'),
none: __('None'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
SidebarEditableItem,
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlDropdownDivider,
GlSearchBoxByType,
GlIcon,
GlLoadingIcon,
},
inject: {
isClassicSidebar: {
default: false,
},
},
props: {
issuableAttribute: {
type: String,
required: true,
validator(value) {
return [IssuableAttributeType.Iteration, IssuableAttributeType.Epic].includes(value);
},
},
workspacePath: {
required: true,
type: String,
},
iid: {
required: true,
type: String,
},
attrWorkspacePath: {
required: true,
type: String,
},
issuableType: {
type: String,
required: true,
validator(value) {
return value === IssuableType.Issue;
},
},
},
apollo: {
currentAttribute: {
query() {
const { current } = this.issuableAttributeQuery;
const { query } = current[this.issuableType];
return query;
},
variables() {
return {
fullPath: this.workspacePath,
iid: this.iid,
};
},
update(data) {
return data?.workspace?.issuable.attribute;
},
error(error) {
createFlash({
message: this.i18n.currentFetchError,
captureError: true,
error,
});
},
},
attributesList: {
query() {
const { list } = this.issuableAttributeQuery;
const { query } = list[this.issuableType];
return query;
},
skip() {
return !this.editing;
},
debounce: 250,
variables() {
return {
fullPath: this.attrWorkspacePath,
title: this.searchTerm,
state: IssuableAttributeState[this.issuableAttribute],
};
},
update(data) {
if (data?.workspace) {
return data?.workspace?.attributes.nodes;
}
return [];
},
error(error) {
createFlash({ message: this.i18n.listFetchError, captureError: true, error });
},
},
},
data() {
return {
searchTerm: '',
editing: false,
updating: false,
selectedTitle: null,
currentAttribute: null,
attributesList: [],
tracking: {
label: 'right_sidebar',
event: 'click_edit_button',
property: this.issuableAttribute,
},
};
},
computed: {
issuableAttributeQuery() {
return issuableAttributesQueries[this.issuableAttribute];
},
attributeTitle() {
return this.currentAttribute?.title || this.i18n.noAttribute;
},
attributeUrl() {
return this.currentAttribute?.webUrl;
},
dropdownText() {
return this.currentAttribute
? this.currentAttribute?.title
: this.$options.i18n[this.issuableAttribute];
},
loading() {
return this.$apollo.queries.currentAttribute.loading;
},
emptyPropsList() {
return this.attributesList.length === 0;
},
attributeTypeTitle() {
return this.$options.i18n[this.issuableAttribute];
},
i18n() {
return {
noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), {
issuableAttribute: this.issuableAttribute,
}),
assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), {
issuableAttribute: this.issuableAttribute,
}),
noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), {
issuableAttribute: this.issuableAttribute,
}),
updateError: sprintf(
s__(
'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.',
),
{ issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
),
listFetchError: sprintf(
s__(
'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.',
),
{ issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
),
currentFetchError: sprintf(
s__(
'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.',
),
{ issuableAttribute: this.issuableAttribute, issuableType: this.issuableType },
),
};
},
},
methods: {
updateAttribute(attributeId) {
if (this.currentAttribute === null && attributeId === null) return;
if (attributeId === this.currentAttribute?.id) return;
this.updating = true;
const selectedAttribute =
Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId);
this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.$options.i18n.none;
const { current } = this.issuableAttributeQuery;
const { mutation } = current[this.issuableType];
this.$apollo
.mutate({
mutation,
variables: {
fullPath: this.workspacePath,
attributeId,
iid: this.iid,
},
})
.then(({ data }) => {
if (data.issuableSetAttribute?.errors?.length) {
createFlash({
message: data.issuableSetAttribute.errors[0],
captureError: true,
error: data.issuableSetAttribute.errors[0],
});
} else {
this.$emit('attribute-updated', data);
}
})
.catch((error) => {
createFlash({ message: this.i18n.updateError, captureError: true, error });
})
.finally(() => {
this.updating = false;
this.searchTerm = '';
this.selectedTitle = null;
});
},
isAttributeChecked(attributeId = undefined) {
return (
attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId)
);
},
showDropdown() {
this.$refs.newDropdown.show();
},
handleOpen() {
this.editing = true;
this.showDropdown();
},
handleClose() {
this.editing = false;
},
setFocus() {
this.$refs.search.focusInput();
},
},
};
</script>
<template>
<sidebar-editable-item
ref="editable"
:title="attributeTypeTitle"
:data-testid="`${issuableAttribute}-edit`"
:tracking="tracking"
:loading="updating || loading"
@open="handleOpen"
@close="handleClose"
>
<template #collapsed>
<div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon">
<gl-icon :size="16" :aria-label="attributeTypeTitle" :name="issuableAttribute" />
<span class="collapse-truncated-title">{{ attributeTitle }}</span>
</div>
<div
:data-testid="`select-${issuableAttribute}`"
:class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
>
<span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span>
<span v-else-if="!currentAttribute" class="gl-text-gray-500">
{{ $options.i18n.none }}
</span>
<gl-link v-else class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl">
{{ attributeTitle }}
</gl-link>
</div>
</template>
<template #default>
<gl-dropdown
ref="newDropdown"
lazy
:header-text="i18n.assignAttribute"
:text="dropdownText"
:loading="loading"
class="gl-w-full"
@shown="setFocus"
>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
:data-testid="`no-${issuableAttribute}-item`"
:is-check-item="true"
:is-checked="isAttributeChecked($options.noAttributeId)"
@click="updateAttribute($options.noAttributeId)"
>
{{ i18n.noAttribute }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-loading-icon
v-if="$apollo.queries.attributesList.loading"
class="gl-py-4"
data-testid="loading-icon-dropdown"
/>
<template v-else>
<gl-dropdown-text v-if="emptyPropsList">
{{ i18n.noAttributesFound }}
</gl-dropdown-text>
<gl-dropdown-item
v-for="attrItem in attributesList"
:key="attrItem.id"
:is-check-item="true"
:is-checked="isAttributeChecked(attrItem.id)"
:data-testid="`${issuableAttribute}-items`"
@click="updateAttribute(attrItem.id)"
>
{{ attrItem.title }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</template>
</sidebar-editable-item>
</template>
import { IssuableType } from '~/issue_show/constants';
import { s__, __ } from '~/locale';
import groupEpicsQuery from './queries/group_epics.query.graphql';
import groupIterationsQuery from './queries/group_iterations.query.graphql';
import projectIssueEpicMutation from './queries/project_issue_epic.mutation.graphql';
import projectIssueEpicQuery from './queries/project_issue_epic.query.graphql';
import projectIssueIterationMutation from './queries/project_issue_iteration.mutation.graphql';
import projectIssueIterationQuery from './queries/project_issue_iteration.query.graphql';
......@@ -31,6 +34,7 @@ export const iterationSelectTextMap = {
};
export const noIteration = null;
export const noAttributeId = null;
export const iterationDisplayState = 'opened';
......@@ -76,3 +80,37 @@ export const iterationsQueries = {
query: groupIterationsQuery,
},
};
const issuableEpicQueries = {
[IssuableType.Issue]: {
query: projectIssueEpicQuery,
mutation: projectIssueEpicMutation,
},
};
const epicsQueries = {
[IssuableType.Issue]: {
query: groupEpicsQuery,
},
};
export const IssuableAttributeType = {
Iteration: 'iteration',
Epic: 'epic',
};
export const IssuableAttributeState = {
[IssuableAttributeType.Iteration]: 'opened',
[IssuableAttributeType.Epic]: 'opened',
};
export const issuableAttributesQueries = {
[IssuableAttributeType.Iteration]: {
current: issuableIterationQueries,
list: iterationsQueries,
},
[IssuableAttributeType.Epic]: {
current: issuableEpicQueries,
list: epicsQueries,
},
};
......@@ -6,11 +6,11 @@ import { store } from '~/notes/stores';
import { apolloProvider } from '~/sidebar/graphql';
import * as CEMountSidebar from '~/sidebar/mount_sidebar';
import CveIdRequest from './components/cve_id_request/cve_id_request_sidebar.vue';
import SidebarItemEpicsSelect from './components/sidebar_item_epics_select.vue';
import SidebarDropdownWidget from './components/sidebar_dropdown_widget.vue';
import SidebarIterationWidget from './components/sidebar_iteration_widget.vue';
import SidebarStatus from './components/status/sidebar_status.vue';
import SidebarWeight from './components/weight/sidebar_weight.vue';
import SidebarStore from './stores/sidebar_store';
import { IssuableAttributeType } from './constants';
Vue.use(VueApollo);
......@@ -75,31 +75,35 @@ function mountCveIdRequestComponent() {
});
}
const mountEpicsSelect = () => {
function mountEpicsSelect() {
const el = document.querySelector('#js-vue-sidebar-item-epics-select');
if (!el) return false;
const { groupId, issueId, epicIssueId, canEdit } = el.dataset;
const sidebarStore = new SidebarStore();
const { groupPath, canEdit, projectPath, issueIid } = el.dataset;
return new Vue({
el,
apolloProvider,
components: {
SidebarItemEpicsSelect,
SidebarDropdownWidget,
},
provide: {
canUpdate: parseBoolean(canEdit),
isClassicSidebar: true,
},
render: (createElement) =>
createElement('sidebar-item-epics-select', {
createElement('sidebar-dropdown-widget', {
props: {
sidebarStore,
groupId: Number(groupId),
issueId: Number(issueId),
epicIssueId: Number(epicIssueId),
canEdit: parseBoolean(canEdit),
attrWorkspacePath: groupPath,
workspacePath: projectPath,
iid: issueIid,
issuableType: IssuableType.Issue,
issuableAttribute: IssuableAttributeType.Epic,
},
}),
});
};
}
function mountIterationSelect() {
const el = document.querySelector('.js-iteration-select');
......
fragment EpicFragment on Epic {
id
title
webUrl
}
#import "./epic.fragment.graphql"
query issueEpics($fullPath: ID!, $title: String, $state: EpicState) {
workspace: group(fullPath: $fullPath) {
attributes: epics(search: $title, state: $state) {
nodes {
...EpicFragment
state
}
}
}
}
mutation projectIssueEpicMutation($fullPath: ID!, $iid: String!, $attributeId: EpicID) {
issuableSetAttribute: issueSetEpic(
input: { projectPath: $fullPath, iid: $iid, epicId: $attributeId }
) {
__typename
errors
issuable: issue {
__typename
id
attribute: epic {
title
id
state
}
}
}
}
#import "./epic.fragment.graphql"
query projectIssueEpic($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
attribute: epic {
...EpicFragment
}
}
}
}
- if issuable_sidebar[:supports_epic]
- if issuable_sidebar[:features_available][:epics]
#js-vue-sidebar-item-epics-select{ data: { can_edit: can_admin_issue?.to_s, group_id: @project.group.id, issue_id: @issuable.id, epic_issue_id: @issuable.epic_issue&.id } }
.block.epic{ data: { testid: 'sidebar-epic' } }
#js-vue-sidebar-item-epics-select{ data: { can_edit: can_admin_issue?.to_s, group_path: group_path, project_path: project_path, issue_iid: issue_iid } }
- else
= render 'shared/promotions/promote_epics'
......@@ -200,7 +200,8 @@ RSpec.describe 'Issue Boards', :js do
it 'displays name of epic and links to it' do
click_card(card2)
expect(epic_widget).to have_link(epic1.title, href: epic_path(epic1))
expect(epic_widget).to have_link(epic1.title)
expect(find_link(epic1.title)[:href]).to end_with(epic_path(epic1))
end
it 'updates the epic associated with the issue' do
......
......@@ -3,14 +3,14 @@
require 'spec_helper'
RSpec.describe 'Epic in issue sidebar', :js do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
let(:epic1) { create(:epic, group: group, title: 'Foo') }
let!(:epic2) { create(:epic, group: group, title: 'Bar') }
let!(:epic3) { create(:epic, group: group, title: 'Baz') }
let(:project) { create(:project, :public, group: group) }
let(:issue) { create(:issue, project: project) }
let!(:epic_issue) { create(:epic_issue, epic: epic1, issue: issue) }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:epic1) { create(:epic, group: group, title: 'Foo') }
let_it_be(:epic2) { create(:epic, group: group, title: 'Bar') }
let_it_be(:epic3) { create(:epic, group: group, title: 'Baz') }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:epic_issue) { create(:epic_issue, epic: epic1, issue: issue) }
shared_examples 'epic in issue sidebar' do
context 'projects within a group' do
......@@ -18,52 +18,52 @@ RSpec.describe 'Epic in issue sidebar', :js do
group.add_owner(user)
sign_in user
visit project_issue_path(project, issue)
wait_for_all_requests
end
it 'shows epic in issue sidebar' do
expect(page.find('.js-epic-block .value')).to have_content(epic1.title)
expect(page.find('[data-testid="sidebar-epic"]')).to have_content(epic1.title)
end
it 'shows edit button in issue sidebar' do
expect(page.find('.js-epic-block .sidebar-dropdown-toggle')).to have_content('Edit')
expect(page.find('[data-testid="sidebar-epic"]')).to have_button('Edit')
end
it 'shows epics select dropdown' do
page.within(find('.js-epic-block')) do
page.find('.sidebar-dropdown-toggle').click
page.within(find('[data-testid="sidebar-epic"]')) do
click_button 'Edit'
wait_for_requests
wait_for_all_requests
expect(page).to have_selector('.js-epic-select', visible: true)
expect(page.all('.gl-new-dropdown-contents .gl-new-dropdown-item').length).to eq(4) # `No Epic` + 3 epics
expect(page).to have_selector('.gl-new-dropdown-contents .gl-new-dropdown-item', count: 4) # `No Epic` + 3 epics
end
end
it 'supports searching for an epic' do
page.within(find('.js-epic-block')) do
page.find('.sidebar-dropdown-toggle').click
page.within(find('[data-testid="sidebar-epic"]')) do
click_button 'Edit'
wait_for_requests
wait_for_all_requests
page.find('.gl-form-input').send_keys('Foo')
wait_for_requests
wait_for_all_requests
expect(page).to have_selector('.gl-new-dropdown-contents .gl-new-dropdown-item', count: 2) # `No Epic` + 1 matching epic
end
end
it 'select an epic from the dropdown' do
page.within(find('.js-epic-block')) do
page.find('.sidebar-dropdown-toggle').click
page.within(find('[data-testid="sidebar-epic"]')) do
click_button 'Edit'
wait_for_requests
wait_for_all_requests
find('.gl-new-dropdown-item', text: epic2.title).click
wait_for_requests
wait_for_all_requests
expect(page.find('.value')).to have_content(epic2.title)
expect(page.find('[data-testid="select-epic"]')).to have_content(epic2.title)
end
end
end
......
......@@ -53,7 +53,7 @@ RSpec.describe "User creates issue", :js do
wait_for_all_requests
page.within(".js-epic-block .js-epic-label") do
page.within('[data-testid="select-epic"]') do
expect(page).to have_content('None')
end
......@@ -67,7 +67,7 @@ RSpec.describe "User creates issue", :js do
wait_for_all_requests
page.within(".js-epic-block .js-epic-label") do
page.within('[data-testid="select-epic"]') do
expect(page).to have_content(epic.title)
end
......
......@@ -3,82 +3,57 @@
exports[`ee/BoardContentSidebar matches the snapshot 1`] = `
<div>
Issue details
<board-sidebar-title-stub />
<boardsidebartitle-stub />
<sidebar-assignees-widget-stub
<sidebarassigneeswidget-stub
fullpath="gitlab-org/gitlab-test"
iid="27"
initialassignees="[object Object],[object Object]"
issuabletype="issue"
/>
<board-editable-item-stub
class="epic"
<sidebardropdownwidget-stub
attr-workspace-path="gitlab-org"
data-testid="sidebar-epic"
handleoffclick="true"
title="Epic"
>
<epics-select-stub
canedit="true"
class="gl-w-full"
epicissueid="0"
groupid="1"
initialepic="[object Object]"
issueid="0"
variant="standalone"
/>
</board-editable-item-stub>
iid="27"
issuable-attribute="epic"
issuable-type="issue"
workspace-path="gitlab-org/gitlab-test"
/>
<div>
<board-sidebar-milestone-select-stub />
<boardsidebarmilestoneselect-stub />
<div
<sidebariterationwidget-stub
class="gl-mt-5"
data-qa-selector="iteration_container"
>
<sidebar-editable-item-stub
canedit="true"
data-testid="iteration-edit-link"
title="Iteration"
tracking="[object Object]"
/>
</div>
iid="27"
issuable-type="issue"
iterations-workspace-path="gitlab-org"
workspace-path="gitlab-org/gitlab-test"
/>
</div>
<board-sidebar-time-tracker-stub
<boardsidebartimetracker-stub
class="swimlanes-sidebar-time-tracker"
/>
<board-sidebar-due-date-stub />
<boardsidebarduedate-stub />
<board-sidebar-labels-select-stub
<boardsidebarlabelsselect-stub
class="labels"
/>
<board-editable-item-stub
<boardsidebarweightinput-stub
class="weight"
data-testid="sidebar-weight"
handleoffclick="true"
title="Weight"
>
<gl-form-stub>
<gl-form-input-stub
min="0"
placeholder="Enter a number"
type="number"
/>
</gl-form-stub>
</board-editable-item-stub>
/>
<sidebar-confidentiality-widget-stub
<sidebarconfidentialitywidget-stub
fullpath="gitlab-org/gitlab-test"
iid="27"
issuabletype="issue"
/>
<sidebar-subscriptions-widget-stub
<sidebarsubscriptionswidget-stub
data-testid="sidebar-notifications"
fullpath="gitlab-org/gitlab-test"
iid="27"
......
import { GlDrawer } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { stubComponent } from 'helpers/stub_component';
import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
......@@ -34,12 +34,12 @@ describe('ee/BoardContentSidebar', () => {
const createComponent = () => {
/*
Dynamically imported components (in our case ee imports)
aren't stubbed automatically in VTU v1:
aren't stubbed automatically when using shallow mount in VTU v1:
https://github.com/vuejs/vue-test-utils/issues/1279.
This requires us to additionally mock apollo or vuex stores.
This requires us to use mount and additionally mock components.
*/
wrapper = shallowMount(BoardContentSidebar, {
wrapper = mount(BoardContentSidebar, {
provide: {
canUpdate: true,
rootPath: '/',
......@@ -53,21 +53,18 @@ describe('ee/BoardContentSidebar', () => {
GlDrawer: stubComponent(GlDrawer, {
template: '<div><slot name="header"></slot><slot></slot></div>',
}),
},
mocks: {
$apollo: {
queries: {
participants: {
loading: false,
},
currentIteration: {
loading: false,
},
iterations: {
loading: false,
},
},
},
BoardEditableItem: true,
BoardSidebarTitle: true,
BoardSidebarTimeTracker: true,
BoardSidebarLabelsSelect: true,
SidebarAssigneesWidget: true,
SidebarConfidentialityWidget: true,
BoardSidebarDueDate: true,
SidebarSubscriptionsWidget: true,
BoardSidebarMilestoneSelect: true,
BoardSidebarWeightInput: true,
SidebarIterationWidget: true,
SidebarDropdownWidget: true,
},
});
};
......
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import BoardSidebarEpicSelect from 'ee/boards/components/sidebar/board_sidebar_epic_select.vue';
import EpicsSelect from 'ee/vue_shared/components/sidebar/epics_select/base.vue';
import { stubComponent } from 'helpers/stub_component';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import getters from '~/boards/stores/getters';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import {
mockIssue3 as mockIssueWithoutEpic,
mockIssueWithEpic,
mockAssignedEpic,
} from '../../mock_data';
jest.mock('~/flash');
const mockGroupId = 7;
describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
let wrapper;
let store;
afterEach(() => {
wrapper.destroy();
store = null;
wrapper = null;
});
const createStore = ({
initialState = {
activeId: mockIssueWithoutEpic.id,
boardItems: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } },
epicsCacheById: {},
epicFetchInProgress: false,
},
actionsMock = {},
} = {}) => {
store = new Vuex.Store({
state: initialState,
getters,
actions: {
...actionsMock,
},
});
};
let epicsSelectHandleEditClick;
const createWrapper = () => {
epicsSelectHandleEditClick = jest.fn();
wrapper = shallowMount(BoardSidebarEpicSelect, {
store,
provide: {
groupId: mockGroupId,
canUpdate: true,
},
stubs: {
BoardEditableItem,
EpicsSelect: stubComponent(EpicsSelect, {
methods: {
toggleFormDropdown: epicsSelectHandleEditClick,
},
}),
},
});
};
const findEpicSelect = () => wrapper.find({ ref: 'epicSelect' });
const findItemWrapper = () => wrapper.find({ ref: 'sidebarItem' });
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findEpicLink = () => wrapper.find(GlLink);
const findBoardEditableItem = () => wrapper.find(BoardEditableItem);
describe('when not editing', () => {
it('expands the milestone dropdown on clicking edit', async () => {
createStore();
createWrapper();
await findBoardEditableItem().vm.$emit('open');
expect(epicsSelectHandleEditClick).toHaveBeenCalled();
});
});
describe('when editing', () => {
beforeEach(() => {
createStore();
createWrapper();
findItemWrapper().vm.$emit('open');
jest.spyOn(wrapper.vm.$refs.sidebarItem, 'collapse');
});
it('collapses BoardEditableItem on clicking edit', async () => {
await findBoardEditableItem().vm.$emit('close');
expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1);
});
it('collapses BoardEditableItem on hiding dropdown', async () => {
await wrapper.find(EpicsSelect).vm.$emit('hide');
expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1);
});
});
it('renders "None" when no epic is assigned to the active issue', async () => {
createStore();
createWrapper();
await wrapper.vm.$nextTick();
expect(findCollapsed().text()).toBe('None');
});
describe('when active issue has an assigned epic', () => {
it('fetches an epic for active issue', () => {
const fetchEpicForActiveIssue = jest.fn(() => Promise.resolve());
createStore({
initialState: {
activeId: mockIssueWithEpic.id,
boardItems: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
epicsCacheById: {},
epicFetchInProgress: true,
},
actionsMock: {
fetchEpicForActiveIssue,
},
});
createWrapper();
expect(fetchEpicForActiveIssue).toHaveBeenCalledTimes(1);
});
it('flashes an error message when fetch fails', async () => {
createStore({
initialState: {
activeId: mockIssueWithEpic.id,
boardItems: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
epicsCacheById: {},
epicFetchInProgress: true,
},
actionsMock: {
fetchEpicForActiveIssue: jest.fn().mockRejectedValue('mayday'),
},
});
createWrapper();
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message: wrapper.vm.$options.i18n.fetchEpicError,
error: 'mayday',
captureError: true,
});
});
it('renders epic title when issue has an assigned epic', async () => {
createStore({
initialState: {
activeId: mockIssueWithEpic.id,
boardItems: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
epicsCacheById: { [mockAssignedEpic.id]: { ...mockAssignedEpic } },
epicFetchInProgress: false,
},
});
createWrapper();
await wrapper.vm.$nextTick();
expect(findEpicLink().isVisible()).toBe(true);
expect(findEpicLink().text()).toBe(mockAssignedEpic.title);
expect(findEpicLink().attributes('href')).toBe(mockAssignedEpic.webPath);
});
});
describe('when epic is selected', () => {
beforeEach(async () => {
createStore({
initialState: {
activeId: mockIssueWithoutEpic.id,
boardItems: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } },
epicsCacheById: {},
epicFetchInProgress: false,
},
});
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(async () => {
// 'setActiveIssueEpic' sets the active issue's epic to the selected epic
// and stores the assigned epic's data in 'epicsCacheById'
store.state.epicFetchInProgress = true;
store.state.boardItems[mockIssueWithoutEpic.id].epic = { ...mockAssignedEpic };
store.state.epicsCacheById = { [mockAssignedEpic.id]: { ...mockAssignedEpic } };
store.state.epicFetchInProgress = false;
});
findEpicSelect().vm.$emit('epicSelect', {
...mockAssignedEpic,
id: getIdFromGraphQLId(mockAssignedEpic.id),
});
await wrapper.vm.$nextTick();
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledWith(mockAssignedEpic.id);
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledTimes(1);
});
it('collapses sidebar and renders epic title', () => {
expect(findEpicLink().isVisible()).toBe(true);
expect(findEpicLink().text()).toBe(mockAssignedEpic.title);
expect(findEpicLink().attributes('href')).toBe(mockAssignedEpic.webPath);
});
describe('when the selected epic did not change', () => {
it('does not commit change to the server', async () => {
createStore();
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation();
findEpicSelect().vm.$emit('epicSelect', null);
await wrapper.vm.$nextTick();
expect(wrapper.vm.setActiveIssueEpic).not.toHaveBeenCalled();
});
});
});
describe('when no epic is selected', () => {
beforeEach(async () => {
createStore({
initialState: {
activeId: mockIssueWithEpic.id,
boardItems: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
epicsCacheById: { [mockAssignedEpic.id]: { ...mockAssignedEpic } },
epicFetchInProgress: false,
},
});
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation(async () => {
// Remove assigned epic from the active issue
store.state.boardItems[mockIssueWithoutEpic.id].epic = null;
});
findEpicSelect().vm.$emit('epicSelect', null);
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders "None"', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe('None');
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledWith(null);
expect(wrapper.vm.setActiveIssueEpic).toHaveBeenCalledTimes(1);
});
});
it('flashes an error when update fails', async () => {
createStore({
actionsMock: {
setActiveIssueEpic: jest.fn().mockRejectedValue('mayday'),
},
});
createWrapper();
findEpicSelect().vm.$emit('epicSelect', { id: 'foo' });
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message: wrapper.vm.$options.i18n.updateEpicError,
error: 'mayday',
captureError: true,
});
});
});
......@@ -27,7 +27,7 @@ import {
mockIssue,
mockGroupIterationsResponse,
mockIteration2,
mockMutationResponse,
mockIterationMutationResponse,
emptyGroupIterationsResponse,
noCurrentIterationResponse,
} from '../mock_data';
......@@ -379,7 +379,7 @@ describe('SidebarIterationWidget', () => {
describe('when dropdown is expanded and user can edit', () => {
let iterationMutationSpy;
beforeEach(async () => {
iterationMutationSpy = jest.fn().mockResolvedValue(mockMutationResponse);
iterationMutationSpy = jest.fn().mockResolvedValue(mockIterationMutationResponse);
await createComponentWithApollo({
requestHandlers: [[projectIssueIterationMutation, iterationMutationSpy]],
......
......@@ -25,6 +25,22 @@ export const mockIteration2 = {
state: 'opened',
};
export const mockEpic1 = {
__typename: 'Epic',
id: 'gid://gitlab/Epic/1',
title: 'Foobar Epic',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/epics/1',
state: 'opened',
};
export const mockEpic2 = {
__typename: 'Epic',
id: 'gid://gitlab/Epic/2',
title: 'Awesome Epic',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/epics/2',
state: 'opened',
};
export const mockGroupIterationsResponse = {
data: {
workspace: {
......@@ -37,6 +53,18 @@ export const mockGroupIterationsResponse = {
},
};
export const mockGroupEpicsResponse = {
data: {
workspace: {
attributes: {
nodes: [mockEpic1, mockEpic2],
},
__typename: 'EpicConnection',
},
__typename: 'Group',
},
};
export const emptyGroupIterationsResponse = {
data: {
workspace: {
......@@ -49,6 +77,18 @@ export const emptyGroupIterationsResponse = {
},
};
export const emptyGroupEpicsResponse = {
data: {
workspace: {
attributes: {
nodes: [],
},
__typename: 'EpicConnection',
},
__typename: 'Group',
},
};
export const noCurrentIterationResponse = {
data: {
workspace: {
......@@ -58,7 +98,16 @@ export const noCurrentIterationResponse = {
},
};
export const mockMutationResponse = {
export const noCurrentEpicResponse = {
data: {
workspace: {
issuable: { id: mockIssueId, attribute: null, __typename: 'Issue' },
__typename: 'Project',
},
},
};
export const mockIterationMutationResponse = {
data: {
issuableSetIteration: {
errors: [],
......@@ -76,3 +125,22 @@ export const mockMutationResponse = {
},
},
};
export const mockEpicMutationResponse = {
data: {
issuableSetAttribute: {
errors: [],
issuable: {
id: 'gid://gitlab/Issue/1',
attribute: {
id: 'gid://gitlab/Epic/2',
title: 'Awesome Epic',
state: 'opened',
__typename: 'Epic',
},
__typename: 'Issue',
},
__typename: 'IssueSetEpicPayload',
},
},
};
......@@ -2,6 +2,8 @@
RSpec.shared_examples 'issue boards sidebar EE' do
context 'epics' do
let(:epic_widget) { find('[data-testid="sidebar-epic"]') }
context 'when epic feature available' do
let_it_be(:epic1) { create(:epic, group: group) }
let_it_be(:epic2) { create(:epic, group: group) }
......@@ -13,25 +15,25 @@ RSpec.shared_examples 'issue boards sidebar EE' do
stub_licensed_features(epics: true)
first_card_with_epic.click
wait_for_requests
end
it 'displays name of epic and links to it' do
page.within('[data-testid="sidebar-epic"]') do
expect(page).to have_link(epic1.title, href: epic_path(epic1))
within(epic_widget) do
expect(page).to have_link(epic1.title)
expect(find_link(epic1.title)[:href]).to end_with(epic_path(epic1))
end
end
it 'updates the epic associated with the issue' do
page.within('[data-testid="sidebar-epic"]') do
find("[data-testid='edit-button']").click
within(epic_widget) do
click_button 'Edit'
wait_for_requests
find('.gl-new-dropdown-item', text: epic2.title).click
wait_for_requests
expect(page).to have_link(epic2.title, href: epic_path(epic2))
expect(page).to have_content(epic2.title)
end
end
......
......@@ -11773,6 +11773,24 @@ msgstr ""
msgid "Drop your files to start your upload."
msgstr ""
msgid "DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}."
msgstr ""
msgid "DropdownWidget|Assign %{issuableAttribute}"
msgstr ""
msgid "DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again."
msgstr ""
msgid "DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again."
msgstr ""
msgid "DropdownWidget|No %{issuableAttribute}"
msgstr ""
msgid "DropdownWidget|No %{issuableAttribute} found"
msgstr ""
msgid "Due Date"
msgstr ""
......@@ -18305,12 +18323,6 @@ msgstr ""
msgid "IssueAnalytics|Weight"
msgstr ""
msgid "IssueBoards|An error occurred while assigning the selected epic to the issue."
msgstr ""
msgid "IssueBoards|An error occurred while fetching the assigned epic of the selected issue."
msgstr ""
msgid "IssueBoards|An error occurred while setting notifications status. Please try again."
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