Commit 24c65e64 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'widgetize-iteration-component-v1' into 'master'

Widgetize sidebar iteration component

See merge request gitlab-org/gitlab!54895
parents b2e1609f 39b7fe0e
...@@ -3,7 +3,12 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; ...@@ -3,7 +3,12 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default { export default {
components: { GlButton, GlLoadingIcon }, components: { GlButton, GlLoadingIcon },
inject: ['canUpdate'], inject: {
canUpdate: {},
isClassicSidebar: {
default: false,
},
},
props: { props: {
title: { title: {
type: String, type: String,
...@@ -83,7 +88,11 @@ export default { ...@@ -83,7 +88,11 @@ export default {
<div class="gl-display-flex gl-align-items-center" @click.self="collapse"> <div class="gl-display-flex gl-align-items-center" @click.self="collapse">
<span class="hide-collapsed" data-testid="title">{{ title }}</span> <span class="hide-collapsed" data-testid="title">{{ title }}</span>
<gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" /> <gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" />
<gl-loading-icon v-if="loading" inline class="gl-mx-auto gl-my-0 hide-expanded" /> <gl-loading-icon
v-if="loading && isClassicSidebar"
inline
class="gl-mx-auto gl-my-0 hide-expanded"
/>
<gl-button <gl-button
v-if="canUpdate" v-if="canUpdate"
variant="link" variant="link"
...@@ -92,6 +101,7 @@ export default { ...@@ -92,6 +101,7 @@ export default {
:data-track-event="tracking.event" :data-track-event="tracking.event"
:data-track-label="tracking.label" :data-track-label="tracking.label"
:data-track-property="tracking.property" :data-track-property="tracking.property"
data-qa-selector="edit_link"
@keyup.esc="toggle" @keyup.esc="toggle"
@click="toggle" @click="toggle"
> >
...@@ -101,7 +111,7 @@ export default { ...@@ -101,7 +111,7 @@ export default {
<div v-show="!edit" data-testid="collapsed-content"> <div v-show="!edit" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot> <slot name="collapsed">{{ __('None') }}</slot>
</div> </div>
<div v-show="edit" data-testid="expanded-content"> <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
<slot :edit="edit"></slot> <slot :edit="edit"></slot>
</div> </div>
</div> </div>
......
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
- if issuable_sidebar[:supports_milestone] - if issuable_sidebar[:supports_milestone]
- milestone = issuable_sidebar[:milestone] || {} - milestone = issuable_sidebar[:milestone] || {}
.block.milestone{ data: { qa_selector: 'milestone_block' } } .block.milestone{ class: 'gl-border-b-0!', data: { qa_selector: 'milestone_block' } }
.sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
= sprite_icon('clock') = sprite_icon('clock')
%span.milestone-title.collapse-truncated-title %span.milestone-title.collapse-truncated-title
...@@ -58,8 +58,10 @@ ...@@ -58,8 +58,10 @@
.selectbox.hide-collapsed .selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: milestone[:id], id: nil = f.hidden_field 'milestone_id', value: milestone[:id], id: nil
= dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }}) = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
- if @project.group.present? && issuable_sidebar[:supports_iterations]
= render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type - if @project.group.present? && issuable_sidebar[:supports_iterations]
.block{ class: 'gl-pt-0!' }
= render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, 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_time_tracking] - if issuable_sidebar[:supports_time_tracking]
#issuable-time-tracker.block #issuable-time-tracker.block
......
<script> <script>
import { GlDrawer } from '@gitlab/ui'; import { GlDrawer } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex'; import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarIterationWidget from 'ee/sidebar/components/sidebar_iteration_widget.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue'; import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
...@@ -11,7 +12,6 @@ import { contentTop } from '~/lib/utils/common_utils'; ...@@ -11,7 +12,6 @@ import { contentTop } from '~/lib/utils/common_utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue'; import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
import BoardSidebarIterationSelect from './sidebar/board_sidebar_iteration_select.vue';
import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue'; import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue';
...@@ -28,12 +28,17 @@ export default { ...@@ -28,12 +28,17 @@ export default {
BoardSidebarDueDate, BoardSidebarDueDate,
BoardSidebarSubscription, BoardSidebarSubscription,
BoardSidebarMilestoneSelect, BoardSidebarMilestoneSelect,
BoardSidebarIterationSelect, SidebarIterationWidget,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
computed: { computed: {
...mapGetters(['isSidebarOpen', 'activeIssue']), ...mapGetters([
...mapState(['sidebarType']), 'isSidebarOpen',
'activeIssue',
'groupPathForActiveIssue',
'projectPathForActiveIssue',
]),
...mapState(['sidebarType', 'issuableType']),
isIssuableSidebar() { isIssuableSidebar() {
return this.sidebarType === ISSUABLE; return this.sidebarType === ISSUABLE;
}, },
...@@ -74,7 +79,13 @@ export default { ...@@ -74,7 +79,13 @@ export default {
<board-sidebar-epic-select /> <board-sidebar-epic-select />
<div> <div>
<board-sidebar-milestone-select /> <board-sidebar-milestone-select />
<board-sidebar-iteration-select class="gl-mt-5" /> <sidebar-iteration-widget
:iid="activeIssue.iid"
:workspace-path="projectPathForActiveIssue"
:iterations-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
class="gl-mt-5"
/>
</div> </div>
<board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
<board-sidebar-due-date /> <board-sidebar-due-date />
......
<script>
import {
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
GlDropdownDivider,
GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { mapGetters } from 'vuex';
import {
iterationSelectTextMap,
iterationDisplayState,
noIteration,
edit,
none,
} from 'ee/sidebar/constants';
import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql';
import currentIterationQuery from 'ee/sidebar/queries/issue_iteration.query.graphql';
import setIssueIterationMutation from 'ee/sidebar/queries/set_iteration_on_issue.mutation.graphql';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import createFlash from '~/flash';
const debounceValue = 250;
export default {
noIteration,
i18n: {
iteration: iterationSelectTextMap.iteration,
noIteration: iterationSelectTextMap.noIteration,
assignIteration: iterationSelectTextMap.assignIteration,
iterationSelectFail: iterationSelectTextMap.iterationSelectFail,
noIterationsFound: iterationSelectTextMap.noIterationsFound,
currentIterationFetchError: iterationSelectTextMap.currentIterationFetchError,
iterationsFetchError: iterationSelectTextMap.iterationsFetchError,
edit,
none,
},
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
BoardEditableItem,
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlDropdownDivider,
GlSearchBoxByType,
GlLoadingIcon,
},
apollo: {
currentIteration: {
query: currentIterationQuery,
variables() {
return {
fullPath: this.projectPathForActiveIssue,
iid: this.activeIssue.iid,
};
},
update(data) {
return data?.project?.issue.iteration;
},
error(error) {
createFlash({ message: this.$options.i18n.currentIterationFetchError });
Sentry.captureException(error);
},
},
iterations: {
query: groupIterationsQuery,
skip() {
return !this.editing;
},
debounce: debounceValue,
variables() {
const search = this.searchTerm === '' ? '' : `"${this.searchTerm}"`;
return {
fullPath: this.groupPathForActiveIssue,
title: search,
state: iterationDisplayState,
};
},
update(data) {
return data?.group?.iterations.nodes || [];
},
error(error) {
createFlash({ message: this.$options.i18n.iterationsFetchError });
Sentry.captureException(error);
},
},
},
data() {
return {
searchTerm: '',
editing: false,
updating: false,
selectedTitle: null,
currentIteration: null,
iterations: [],
};
},
computed: {
...mapGetters(['activeIssue', 'projectPathForActiveIssue', 'groupPathForActiveIssue']),
showCurrentIteration() {
return this.currentIteration !== null && !this.editing;
},
iteration() {
return this.findIteration(this.currentIteration);
},
iterationTitle() {
return this.currentIteration?.title;
},
iterationUrl() {
return this.currentIteration?.webUrl;
},
dropdownText() {
return this.currentIteration ? this.currentIteration?.title : this.$options.i18n.iteration;
},
showNoIterationContent() {
return !this.updating && !this.currentIteration;
},
loading() {
return this.updating || this.$apollo.queries.currentIteration.loading;
},
noIterations() {
return this.iterations.length === 0;
},
},
methods: {
handleOpen() {
this.editing = true;
this.$refs.dropdown.show();
},
handleClose() {
this.$refs.editableItem.collapse();
},
findIteration(iterationId) {
return this.iterations.find(({ id }) => id === iterationId);
},
setIteration(iterationId) {
this.editing = false;
if (iterationId === this.currentIteration?.id) return;
this.updating = true;
const selectedIteration = this.findIteration(iterationId);
this.selectedTitle = selectedIteration ? selectedIteration.title : this.$options.i18n.none;
this.$apollo
.mutate({
mutation: setIssueIterationMutation,
variables: {
projectPath: this.projectPathForActiveIssue,
iterationId,
iid: this.activeIssue.iid,
},
})
.then(({ data }) => {
if (data.issueSetIteration?.errors?.length) {
createFlash(data.issueSetIteration.errors[0]);
}
})
.catch(() => {
const { iterationSelectFail } = iterationSelectTextMap;
createFlash(iterationSelectFail);
})
.finally(() => {
this.updating = false;
this.searchTerm = '';
this.selectedTitle = null;
this.editing = false;
});
},
isIterationChecked(iterationId = undefined) {
return (
iterationId === this.currentIteration?.id || (!this.currentIteration?.id && !iterationId)
);
},
},
};
</script>
<template>
<board-editable-item
ref="editableItem"
:title="$options.i18n.iteration"
:loading="loading"
data-testid="iteration"
@open="handleOpen"
@close="handleClose"
>
<template #collapsed>
<gl-link v-if="showCurrentIteration" :href="iterationUrl"
><strong class="gl-text-gray-900">{{ iterationTitle }}</strong></gl-link
>
</template>
<gl-dropdown
ref="dropdown"
lazy
:header-text="$options.i18n.assignIteration"
:text="dropdownText"
:loading="loading"
class="gl-w-full"
@hide="handleClose"
>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
data-testid="no-iteration-item"
:is-check-item="true"
:is-checked="isIterationChecked($options.noIteration)"
@click="setIteration($options.noIteration)"
>
{{ $options.i18n.noIteration }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-loading-icon
v-if="$apollo.queries.iterations.loading"
class="gl-py-4"
data-testid="loading-icon-dropdown"
/>
<template v-else>
<gl-dropdown-text v-if="noIterations">
{{ $options.i18n.noIterationsFound }}
</gl-dropdown-text>
<gl-dropdown-item
v-for="iterationItem in iterations"
:key="iterationItem.id"
:is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)"
data-testid="iteration-items"
@click="setIteration(iterationItem.id)"
>{{ iterationItem.title }}</gl-dropdown-item
>
</template>
</gl-dropdown>
</board-editable-item>
</template>
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { iterationSelectTextMap, iterationDisplayState } from '../constants'; import { iterationSelectTextMap, iterationDisplayState } from '../constants';
import groupIterationsQuery from '../queries/group_iterations.query.graphql'; import groupIterationsQuery from '../queries/iterations.query.graphql';
export default { export default {
directives: { directives: {
......
<script> <script>
import { import {
GlButton,
GlLink, GlLink,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
...@@ -13,11 +12,16 @@ import { ...@@ -13,11 +12,16 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { iterationSelectTextMap, iterationDisplayState, noIteration } from '../constants'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import groupIterationsQuery from '../queries/group_iterations.query.graphql'; import {
import currentIterationQuery from '../queries/issue_iteration.query.graphql'; iterationSelectTextMap,
import setIssueIterationMutation from '../queries/set_iteration_on_issue.mutation.graphql'; iterationDisplayState,
noIteration,
issuableIterationQueries,
iterationsQueries,
} from '../constants';
export default { export default {
noIteration, noIteration,
...@@ -32,11 +36,18 @@ export default { ...@@ -32,11 +36,18 @@ export default {
edit: __('Edit'), edit: __('Edit'),
none: __('None'), none: __('None'),
}, },
issuableIterationQueries,
iterationsQueries,
tracking: {
label: 'right_sidebar',
property: 'iteration',
event: 'click_edit_button',
},
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
components: { components: {
GlButton, SidebarEditableItem,
GlLink, GlLink,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
...@@ -46,35 +57,47 @@ export default { ...@@ -46,35 +57,47 @@ export default {
GlIcon, GlIcon,
GlLoadingIcon, GlLoadingIcon,
}, },
inject: {
isClassicSidebar: {
default: false,
},
},
props: { props: {
canEdit: { workspacePath: {
required: true, required: true,
type: Boolean, type: String,
}, },
groupPath: { iid: {
required: true, required: true,
type: String, type: String,
}, },
projectPath: { iterationsWorkspacePath: {
required: true, required: true,
type: String, type: String,
}, },
issueIid: { issuableType: {
required: true,
type: String, type: String,
required: true,
validator(value) {
// Add supported IssuableType here along with graphql queries
// as this widget is used for addtional issuable types.
return [IssuableType.Issue].includes(value);
},
}, },
}, },
apollo: { apollo: {
currentIteration: { currentIteration: {
query: currentIterationQuery, query() {
return issuableIterationQueries[this.issuableType].query;
},
variables() { variables() {
return { return {
fullPath: this.projectPath, fullPath: this.workspacePath,
iid: this.issueIid, iid: this.iid,
}; };
}, },
update(data) { update(data) {
return data?.project?.issue.iteration; return data?.workspace?.issuable.iteration;
}, },
error(error) { error(error) {
createFlash({ message: this.$options.i18n.currentIterationFetchError }); createFlash({ message: this.$options.i18n.currentIterationFetchError });
...@@ -82,22 +105,22 @@ export default { ...@@ -82,22 +105,22 @@ export default {
}, },
}, },
iterations: { iterations: {
query: groupIterationsQuery, query() {
return iterationsQueries[this.issuableType].query;
},
skip() { skip() {
return !this.editing; return !this.editing;
}, },
debounce: 250, debounce: 250,
variables() { variables() {
const search = this.searchTerm === '' ? '' : `"${this.searchTerm}"`;
return { return {
fullPath: this.groupPath, fullPath: this.iterationsWorkspacePath,
title: search, title: this.searchTerm,
state: iterationDisplayState, state: iterationDisplayState,
}; };
}, },
update(data) { update(data) {
return data?.group?.iterations.nodes || []; return data?.workspace?.iterations.nodes || [];
}, },
error(error) { error(error) {
createFlash({ message: this.$options.i18n.iterationsFetchError }); createFlash({ message: this.$options.i18n.iterationsFetchError });
...@@ -128,32 +151,16 @@ export default { ...@@ -128,32 +151,16 @@ export default {
dropdownText() { dropdownText() {
return this.currentIteration ? this.currentIteration?.title : this.$options.i18n.iteration; return this.currentIteration ? this.currentIteration?.title : this.$options.i18n.iteration;
}, },
showNoIterationContent() {
return !this.updating && !this.currentIteration;
},
loading() { loading() {
return this.updating || this.$apollo.queries.currentIteration.loading; return this.$apollo.queries.currentIteration.loading;
}, },
noIterations() { noIterations() {
return this.iterations.length === 0; return this.iterations.length === 0;
}, },
}, },
mounted() {
document.addEventListener('click', this.handleOffClick);
},
beforeDestroy() {
document.removeEventListener('click', this.handleOffClick);
},
methods: { methods: {
toggleDropdown() { updateIteration(iterationId) {
this.editing = !this.editing; if (this.currentIteration === null && iterationId === null) return;
if (this.editing) {
this.showDropdown();
}
},
setIteration(iterationId) {
this.editing = false;
if (iterationId === this.currentIteration?.id) return; if (iterationId === this.currentIteration?.id) return;
this.updating = true; this.updating = true;
...@@ -163,22 +170,24 @@ export default { ...@@ -163,22 +170,24 @@ export default {
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: setIssueIterationMutation, mutation: issuableIterationQueries[this.issuableType].mutation,
variables: { variables: {
projectPath: this.projectPath, fullPath: this.workspacePath,
iterationId, iterationId,
iid: this.issueIid, iid: this.iid,
}, },
}) })
.then(({ data }) => { .then(({ data }) => {
if (data.issueSetIteration?.errors?.length) { if (data.issuableSetIteration?.errors?.length) {
createFlash(data.issueSetIteration.errors[0]); createFlash(data.issuableSetIteration.errors[0]);
Sentry.captureException(data.issuableSetIteration.errors[0]);
} else {
this.$emit('iteration-updated', data);
} }
}) })
.catch(() => { .catch((error) => {
const { iterationSelectFail } = iterationSelectTextMap; createFlash(this.$options.i18n.iterationSelectFail);
Sentry.captureException(error);
createFlash(iterationSelectFail);
}) })
.finally(() => { .finally(() => {
this.updating = false; this.updating = false;
...@@ -186,13 +195,6 @@ export default { ...@@ -186,13 +195,6 @@ export default {
this.selectedTitle = null; this.selectedTitle = null;
}); });
}, },
handleOffClick(event) {
if (!this.editing) return;
if (!this.$refs.newDropdown.$el.contains(event.target)) {
this.toggleDropdown(event);
}
},
isIterationChecked(iterationId = undefined) { isIterationChecked(iterationId = undefined) {
return ( return (
iterationId === this.currentIteration?.id || (!this.currentIteration?.id && !iterationId) iterationId === this.currentIteration?.id || (!this.currentIteration?.id && !iterationId)
...@@ -201,6 +203,13 @@ export default { ...@@ -201,6 +203,13 @@ export default {
showDropdown() { showDropdown() {
this.$refs.newDropdown.show(); this.$refs.newDropdown.show();
}, },
handleOpen() {
this.editing = true;
this.showDropdown();
},
handleClose() {
this.editing = false;
},
setFocus() { setFocus() {
this.$refs.search.focusInput(); this.$refs.search.focusInput();
}, },
...@@ -210,80 +219,78 @@ export default { ...@@ -210,80 +219,78 @@ export default {
<template> <template>
<div data-qa-selector="iteration_container"> <div data-qa-selector="iteration_container">
<div v-gl-tooltip class="sidebar-collapsed-icon"> <sidebar-editable-item
<gl-icon :size="16" :aria-label="$options.i18n.iteration" name="iteration" /> ref="editable"
<span class="collapse-truncated-title">{{ iterationTitle }}</span> :title="$options.i18n.iteration"
</div> data-testid="iteration-edit-link"
<div class="hide-collapsed gl-mt-5"> :tracking="$options.tracking"
{{ $options.i18n.iteration }} :loading="updating || loading"
<gl-loading-icon @open="handleOpen"
v-if="loading" @close="handleClose"
class="gl-ml-2"
:inline="true"
data-testid="loading-icon-title"
/>
<gl-button
v-if="canEdit"
variant="link"
class="js-sidebar-dropdown-toggle edit-link gl-shadow-none float-right gl-reset-color! gl-hover-text-blue-800! gl-mt-1"
data-testid="iteration-edit-link"
data-track-label="right_sidebar"
data-track-property="iteration"
data-track-event="click_edit_button"
data-qa-selector="edit_iteration_link"
@click.stop="toggleDropdown"
>{{ $options.i18n.edit }}</gl-button
>
</div>
<div v-if="!editing" data-testid="select-iteration" class="hide-collapsed">
<strong v-if="updating">{{ selectedTitle }}</strong>
<span v-else-if="showNoIterationContent" class="gl-text-gray-500">{{
$options.i18n.none
}}</span>
<gl-link v-else data-qa-selector="iteration_link" :href="iterationUrl"
><strong>{{ iterationTitle }}</strong></gl-link
>
</div>
<gl-dropdown
v-show="editing"
ref="newDropdown"
lazy
:header-text="$options.i18n.assignIteration"
:text="dropdownText"
:loading="loading"
class="gl-w-full"
@shown="setFocus"
@hidden="toggleDropdown"
> >
<gl-search-box-by-type ref="search" v-model="searchTerm" /> <template #collapsed>
<gl-dropdown-item <div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon">
data-testid="no-iteration-item" <gl-icon :size="16" :aria-label="$options.i18n.iteration" name="iteration" />
:is-check-item="true" <span class="collapse-truncated-title">{{ iterationTitle }}</span>
:is-checked="isIterationChecked($options.noIteration)" </div>
@click="setIteration($options.noIteration)" <div
> data-testid="select-iteration"
{{ $options.i18n.noIteration }} :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
</gl-dropdown-item> >
<gl-dropdown-divider /> <strong v-if="updating">{{ selectedTitle }}</strong>
<gl-loading-icon <span v-else-if="!updating && !currentIteration" class="gl-text-gray-500">{{
v-if="$apollo.queries.iterations.loading" $options.i18n.none
class="gl-py-4" }}</span>
data-testid="loading-icon-dropdown" <gl-link
/> v-else
<template v-else> data-qa-selector="iteration_link"
<gl-dropdown-text v-if="noIterations"> class="gl-text-gray-900! gl-font-weight-bold"
{{ $options.i18n.noIterationsFound }} :href="iterationUrl"
</gl-dropdown-text> ><strong>{{ iterationTitle }}</strong></gl-link
<gl-dropdown-item >
v-for="iterationItem in iterations" </div>
:key="iterationItem.id" </template>
:is-check-item="true" <template #default>
:is-checked="isIterationChecked(iterationItem.id)" <gl-dropdown
data-testid="iteration-items" ref="newDropdown"
@click="setIteration(iterationItem.id)" lazy
>{{ iterationItem.title }}</gl-dropdown-item :header-text="$options.i18n.assignIteration"
: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-iteration-item"
:is-check-item="true"
:is-checked="isIterationChecked($options.noIteration)"
@click="updateIteration($options.noIteration)"
>
{{ $options.i18n.noIteration }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-loading-icon
v-if="$apollo.queries.iterations.loading"
class="gl-py-4"
data-testid="loading-icon-dropdown"
/>
<template v-else>
<gl-dropdown-text v-if="noIterations">
{{ $options.i18n.noIterationsFound }}
</gl-dropdown-text>
<gl-dropdown-item
v-for="iterationItem in iterations"
:key="iterationItem.id"
:is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)"
data-testid="iteration-items"
@click="updateIteration(iterationItem.id)"
>{{ iterationItem.title }}</gl-dropdown-item
>
</template>
</gl-dropdown>
</template> </template>
</gl-dropdown> </sidebar-editable-item>
</div> </div>
</template> </template>
import { IssuableType } from '~/issue_show/constants';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import groupIterationsQuery from './queries/group_iterations.query.graphql';
import projectIssueIterationMutation from './queries/project_issue_iteration.mutation.graphql';
import projectIssueIterationQuery from './queries/project_issue_iteration.query.graphql';
export const healthStatus = { export const healthStatus = {
ON_TRACK: 'onTrack', ON_TRACK: 'onTrack',
...@@ -59,3 +63,16 @@ export const CVE_ID_REQUEST_SIDEBAR_I18N = { ...@@ -59,3 +63,16 @@ export const CVE_ID_REQUEST_SIDEBAR_I18N = {
), ),
learnMore: __('Learn more'), learnMore: __('Learn more'),
}; };
export const issuableIterationQueries = {
[IssuableType.Issue]: {
query: projectIssueIterationQuery,
mutation: projectIssueIterationMutation,
},
};
export const iterationsQueries = {
[IssuableType.Issue]: {
query: groupIterationsQuery,
},
};
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { IssuableType } from '~/issue_show/constants';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import { store } from '~/notes/stores'; import { store } from '~/notes/stores';
import { apolloProvider } from '~/sidebar/graphql'; import { apolloProvider } from '~/sidebar/graphql';
import * as CEMountSidebar from '~/sidebar/mount_sidebar'; import * as CEMountSidebar from '~/sidebar/mount_sidebar';
import CveIdRequest from './components/cve_id_request/cve_id_request_sidebar.vue'; import CveIdRequest from './components/cve_id_request/cve_id_request_sidebar.vue';
import IterationSelect from './components/iteration_select.vue';
import SidebarItemEpicsSelect from './components/sidebar_item_epics_select.vue'; import SidebarItemEpicsSelect from './components/sidebar_item_epics_select.vue';
import SidebarIterationWidget from './components/sidebar_iteration_widget.vue';
import SidebarStatus from './components/status/sidebar_status.vue'; import SidebarStatus from './components/status/sidebar_status.vue';
import SidebarWeight from './components/weight/sidebar_weight.vue'; import SidebarWeight from './components/weight/sidebar_weight.vue';
import SidebarStore from './stores/sidebar_store'; import SidebarStore from './stores/sidebar_store';
...@@ -106,21 +107,26 @@ function mountIterationSelect() { ...@@ -106,21 +107,26 @@ function mountIterationSelect() {
if (!el) { if (!el) {
return false; return false;
} }
const { groupPath, canEdit, projectPath, issueIid } = el.dataset; const { groupPath, canEdit, projectPath, issueIid } = el.dataset;
return new Vue({ return new Vue({
el, el,
apolloProvider, apolloProvider,
components: { components: {
IterationSelect, SidebarIterationWidget,
},
provide: {
canUpdate: parseBoolean(canEdit),
isClassicSidebar: true,
}, },
render: (createElement) => render: (createElement) =>
createElement('iteration-select', { createElement('sidebar-iteration-widget', {
props: { props: {
groupPath, iterationsWorkspacePath: groupPath,
canEdit: parseBoolean(canEdit), workspacePath: projectPath,
projectPath, iid: issueIid,
issueIid, issuableType: IssuableType.Issue,
}, },
}), }),
}); });
......
query groupIterations($fullPath: ID!, $title: String, $state: IterationState) { #import "./iteration.fragment.graphql"
group(fullPath: $fullPath) {
query issueIterations($fullPath: ID!, $title: String, $state: IterationState) {
workspace: group(fullPath: $fullPath) {
__typename
iterations(title: $title, state: $state) { iterations(title: $title, state: $state) {
nodes { nodes {
id ...IterationFragment
title
state state
webUrl
} }
} }
} }
......
fragment IterationFragment on Iteration {
id
title
webUrl
}
#import "./iteration.fragment.graphql"
query issueIterations($fullPath: ID!, $title: String, $state: IterationState) {
group(fullPath: $fullPath) {
iterations(title: $title, state: $state) {
nodes {
...IterationFragment
state
}
}
}
}
mutation projectIssueIterationMutation($fullPath: ID!, $iid: String!, $iterationId: ID) {
issuableSetIteration: issueSetIteration(
input: { projectPath: $fullPath, iid: $iid, iterationId: $iterationId }
) {
__typename
errors
issuable: issue {
__typename
id
iteration {
title
id
state
}
}
}
}
#import "./iteration.fragment.graphql"
query projectIssueIteration($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
iteration {
...IterationFragment
}
}
}
}
---
title: Add a separator between milestone and iteration dropdown in sidebars
merge_request: 54895
author:
type: other
...@@ -191,7 +191,9 @@ RSpec.describe 'Issue Sidebar' do ...@@ -191,7 +191,9 @@ RSpec.describe 'Issue Sidebar' do
end end
def find_and_click_edit_iteration def find_and_click_edit_iteration
page.find('[data-testid="iteration-edit-link"]').click page.find('[data-testid="iteration-edit-link"] [data-testid="edit-button"]').click
wait_for_all_requests
end end
def select_iteration(iteration_name) def select_iteration(iteration_name)
......
...@@ -2,15 +2,15 @@ import { GlDrawer } from '@gitlab/ui'; ...@@ -2,15 +2,15 @@ import { GlDrawer } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue'; import BoardContentSidebar from 'ee_component/boards/components/board_content_sidebar.vue';
import BoardSidebarIterationSelect from 'ee_component/boards/components/sidebar/board_sidebar_iteration_select.vue'; import SidebarIterationWidget from 'ee_component/sidebar/components/sidebar_iteration_widget';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue'; import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import { ISSUABLE } from '~/boards/constants'; import { ISSUABLE, issuableTypes } from '~/boards/constants';
import { mockIssue } from '../mock_data'; import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data';
describe('ee/BoardContentSidebar', () => { describe('ee/BoardContentSidebar', () => {
let wrapper; let wrapper;
...@@ -22,9 +22,12 @@ describe('ee/BoardContentSidebar', () => { ...@@ -22,9 +22,12 @@ describe('ee/BoardContentSidebar', () => {
sidebarType: ISSUABLE, sidebarType: ISSUABLE,
issues: { [mockIssue.id]: mockIssue }, issues: { [mockIssue.id]: mockIssue },
activeId: mockIssue.id, activeId: mockIssue.id,
issuableType: issuableTypes.issue,
}, },
getters: { getters: {
activeIssue: () => mockIssue, activeIssue: () => mockIssue,
projectPathForActiveIssue: () => mockIssueProjectPath,
groupPathForActiveIssue: () => mockIssueGroupPath,
isSidebarOpen: () => true, isSidebarOpen: () => true,
...mockGetters, ...mockGetters,
}, },
...@@ -102,8 +105,8 @@ describe('ee/BoardContentSidebar', () => { ...@@ -102,8 +105,8 @@ describe('ee/BoardContentSidebar', () => {
expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true); expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true);
}); });
it('renders BoardSidebarIterationSelect', () => { it('renders SidebarIterationWidget', () => {
expect(wrapper.find(BoardSidebarIterationSelect).exists()).toBe(true); expect(wrapper.find(SidebarIterationWidget).exists()).toBe(true);
}); });
describe('when we emit close', () => { describe('when we emit close', () => {
......
import { GlDropdown, GlDropdownItem, GlDropdownText, GlLink, GlSearchBoxByType } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import BoardSidebarIterationSelect from 'ee/boards/components/sidebar/board_sidebar_iteration_select.vue';
import { iterationSelectTextMap, iterationDisplayState } from 'ee/sidebar/constants';
import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql';
import currentIterationQuery from 'ee/sidebar/queries/issue_iteration.query.graphql';
import setIssueIterationMutation from 'ee/sidebar/queries/set_iteration_on_issue.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import getters from '~/boards/stores/getters';
import createFlash from '~/flash';
import {
mockIssue2 as mockIssue,
mockProjectPath,
mockGroupPath,
mockIterationsResponse,
mockIteration2,
mockMutationResponse,
emptyIterationsResponse,
noCurrentIterationResponse,
} from '../../../sidebar/mock_data';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('BoardSidebarIterationSelect', () => {
let wrapper;
let store;
let mockApollo;
const promiseData = { issueSetIteration: { issue: { iteration: { id: '123' } } } };
const firstErrorMsg = 'first error';
const promiseWithErrors = {
...promiseData,
issueSetIteration: { ...promiseData.issueSetIteration, errors: [firstErrorMsg] },
};
const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData });
const mutationError = () => jest.fn().mockRejectedValue();
const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors });
const findGlLink = () => wrapper.find(GlLink);
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownText = () => wrapper.find(GlDropdownText);
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findDropdownItemWithText = (text) =>
findAllDropdownItems().wrappers.find((x) => x.text() === text);
const findBoardEditableItem = () => wrapper.find(BoardEditableItem);
const findIterationItems = () => wrapper.findByTestId('iteration-items');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findNoIterationItem = () => wrapper.findByTestId('no-iteration-item');
const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown');
const clickEdit = async () => {
findBoardEditableItem().vm.$emit('open');
await wrapper.vm.$nextTick();
};
const createStore = ({
initialState = {
activeId: mockIssue.id,
boardItems: { [mockIssue.id]: { ...mockIssue } },
},
} = {}) => {
store = new Vuex.Store({
state: initialState,
getters,
});
};
const createComponentWithApollo = async ({
requestHandlers = [],
currentIterationSpy = jest.fn().mockResolvedValue(noCurrentIterationResponse),
groupIterationsSpy = jest.fn().mockResolvedValue(mockIterationsResponse),
} = {}) => {
localVue.use(VueApollo);
mockApollo = createMockApollo([
[currentIterationQuery, currentIterationSpy],
[groupIterationsQuery, groupIterationsSpy],
...requestHandlers,
]);
wrapper = extendedWrapper(
shallowMount(BoardSidebarIterationSelect, {
localVue,
store,
apolloProvider: mockApollo,
provide: {
canUpdate: true,
},
stubs: {
BoardEditableItem,
},
}),
);
wrapper.vm.$refs.dropdown.show = jest.fn();
};
const createComponent = ({
data = {},
mutationPromise = mutationSuccess,
queries = {},
stubs = { GlSearchBoxByType },
} = {}) => {
createStore();
wrapper = extendedWrapper(
shallowMount(BoardSidebarIterationSelect, {
localVue,
store,
data() {
return data;
},
provide: {
canUpdate: true,
},
mocks: {
$apollo: {
mutate: mutationPromise(),
queries: {
currentIteration: { loading: false },
iterations: { loading: false },
...queries,
},
},
},
stubs: {
BoardEditableItem,
...stubs,
},
}),
);
wrapper.vm.$refs.dropdown.show = jest.fn();
wrapper.vm.$refs.editableItem.collapse = jest.fn();
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when not editing', () => {
beforeEach(() => {
createComponent({
data: {
currentIteration: { id: 'id', title: 'title', webUrl: 'webUrl' },
},
stubs: {
GlDropdown,
},
});
});
it('shows the current iteration', () => {
expect(findCollapsed().text()).toBe('title');
});
it('links to the current iteration', () => {
expect(findGlLink().attributes().href).toBe('webUrl');
});
describe('when current iteration does not exist', () => {
it('renders "None" as the selected iteration title', () => {
createComponent({
stubs: {
GlDropdown,
},
});
expect(findCollapsed().text()).toBe('None');
});
});
it('expands the dropdown on clicking edit', async () => {
createComponent();
await clickEdit();
expect(wrapper.vm.$refs.dropdown.show).toHaveBeenCalledTimes(1);
});
});
describe('when user is editing', () => {
describe('when rendering the dropdown', () => {
it('collapses BoardEditableItem on clicking edit', async () => {
createComponent();
await findBoardEditableItem().vm.$emit('close');
expect(wrapper.vm.$refs.editableItem.collapse).toHaveBeenCalledTimes(1);
});
it('collapses BoardEditableItem on hiding dropdown', async () => {
createComponent();
await findDropdown().vm.$emit('hide');
expect(wrapper.vm.$refs.editableItem.collapse).toHaveBeenCalledTimes(1);
});
it('shows a loading spinner while fetching a list of iterations', async () => {
createComponent({
queries: {
iterations: { loading: true },
},
});
await clickEdit();
expect(findLoadingIconDropdown().exists()).toBe(true);
});
describe('GlDropdownItem with the right title and id', () => {
const id = 'id';
const title = 'title';
beforeEach(async () => {
createComponent({
data: { iterations: [{ id, title }], currentIteration: { id, title } },
});
await clickEdit();
});
it('does not show a loading spinner', () => {
expect(findLoadingIconDropdown().exists()).toBe(false);
});
it('renders title $title', () => {
expect(findDropdownItemWithText(title).text()).toBe(title);
});
it('checks the correct dropdown item', () => {
expect(
findAllDropdownItems()
.filter((w) => w.props('isChecked') === true)
.at(0)
.text(),
).toBe(title);
});
});
describe('when no data is assigned', () => {
beforeEach(async () => {
createComponent();
await clickEdit();
});
it('finds GlDropdownItem with "No iteration"', () => {
expect(findNoIterationItem().text()).toBe('No iteration');
});
it('"No iteration" is checked', () => {
expect(findNoIterationItem().props('isChecked')).toBe(true);
});
it('does not render any dropdown item', () => {
expect(findIterationItems().exists()).toBe(false);
});
});
describe('when clicking on dropdown item', () => {
describe('when currentIteration is equal to iteration id', () => {
it('does not call setIssueIteration mutation', async () => {
createComponent({
data: {
iterations: [{ id: 'id', title: 'title' }],
currentIteration: { id: 'id', title: 'title' },
},
});
await clickEdit();
findDropdownItemWithText('title').vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0);
});
});
describe('when currentIteration is not equal to iteration id', () => {
describe('when error', () => {
const bootstrapComponent = (mutationResp) => {
createComponent({
data: {
iterations: [
{ id: '123', title: '123' },
{ id: 'id', title: 'title' },
],
currentIteration: '123',
},
mutationPromise: mutationResp,
});
};
describe.each`
description | mutationResp | expectedMsg
${'top-level error'} | ${mutationError} | ${iterationSelectTextMap.iterationSelectFail}
${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg}
`(`$description`, ({ mutationResp, expectedMsg }) => {
beforeEach(async () => {
bootstrapComponent(mutationResp);
await clickEdit();
findDropdownItemWithText('title').vm.$emit('click');
});
it('calls createFlash with $expectedMsg', async () => {
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith(expectedMsg);
});
});
});
});
});
});
describe('when a user is searching', () => {
describe('when search result is not found', () => {
it('renders "No iterations found"', async () => {
createComponent();
await clickEdit();
findSearchBox().vm.$emit('input', 'non existing iterations');
await wrapper.vm.$nextTick();
expect(findDropdownText().text()).toBe('No iterations found');
});
});
});
});
describe('With mock apollo', () => {
let error;
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
error = new Error('mayday');
});
describe('when clicking on dropdown item', () => {
describe('when currentIteration is not equal to iteration id', () => {
let setIssueIterationSpy;
describe('when update is successful', () => {
setIssueIterationSpy = jest.fn().mockResolvedValue(mockMutationResponse);
beforeEach(async () => {
createComponentWithApollo({
requestHandlers: [[setIssueIterationMutation, setIssueIterationSpy]],
});
await clickEdit();
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
findDropdownItemWithText(mockIteration2.title).vm.$emit('click');
});
it('calls setIssueIteration mutation', () => {
expect(setIssueIterationSpy).toHaveBeenCalledWith({
iid: mockIssue.iid,
iterationId: mockIteration2.id,
projectPath: mockProjectPath,
});
});
it('sets the value returned from the mutation to currentIteration', async () => {
expect(findCollapsed().text()).toBe(mockIteration2.title);
});
});
});
});
describe('currentIterations', () => {
it('should call createFlash and Sentry if currentIterations query fails', async () => {
createComponentWithApollo({
currentIterationSpy: jest.fn().mockRejectedValue(error),
});
await waitForPromises();
expect(createFlash).toHaveBeenNthCalledWith(1, {
message: wrapper.vm.$options.i18n.currentIterationFetchError,
});
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
});
});
describe('iterations', () => {
let groupIterationsSpy;
it('should call createFlash and Sentry if iterations query fails', async () => {
createComponentWithApollo({
groupIterationsSpy: jest.fn().mockRejectedValue(error),
});
await clickEdit();
jest.runOnlyPendingTimers();
await waitForPromises();
expect(createFlash).toHaveBeenNthCalledWith(1, {
message: wrapper.vm.$options.i18n.iterationsFetchError,
});
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
});
it('only fetches iterations when dropdown is opened', async () => {
groupIterationsSpy = jest.fn().mockResolvedValueOnce(emptyIterationsResponse);
createComponentWithApollo({ groupIterationsSpy });
await wrapper.vm.$nextTick();
jest.runOnlyPendingTimers();
expect(groupIterationsSpy).not.toHaveBeenCalled();
await clickEdit();
jest.runOnlyPendingTimers();
expect(groupIterationsSpy).toHaveBeenCalled();
});
describe('when a user is searching', () => {
const mockSearchTerm = 'foobar';
beforeEach(async () => {
groupIterationsSpy = jest.fn().mockResolvedValueOnce(emptyIterationsResponse);
createComponentWithApollo({ groupIterationsSpy });
await clickEdit();
});
it('sends a groupIterations query with the entered search term "foo"', async () => {
findSearchBox().vm.$emit('input', mockSearchTerm);
await wrapper.vm.$nextTick();
jest.runOnlyPendingTimers();
expect(groupIterationsSpy).toHaveBeenNthCalledWith(1, {
fullPath: mockGroupPath,
title: `"${mockSearchTerm}"`,
state: iterationDisplayState,
});
});
});
});
});
});
...@@ -134,16 +134,19 @@ export const rawIssue = { ...@@ -134,16 +134,19 @@ export const rawIssue = {
}, },
}; };
export const mockIssueGroupPath = 'gitlab-org';
export const mockIssueProjectPath = `${mockIssueGroupPath}/gitlab-test`;
export const mockIssue = { export const mockIssue = {
id: '436', id: '436',
iid: '27', iid: '27',
title: 'Issue 1', title: 'Issue 1',
referencePath: '#27', referencePath: `${mockIssueProjectPath}#27`,
dueDate: null, dueDate: null,
timeEstimate: 0, timeEstimate: 0,
weight: null, weight: null,
confidential: false, confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/27', path: `/${mockIssueProjectPath}/-/issues/27`,
assignees, assignees,
labels, labels,
epic: { epic: {
......
...@@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; ...@@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import IterationDropdown from 'ee/sidebar/components/iteration_dropdown.vue'; import IterationDropdown from 'ee/sidebar/components/iteration_dropdown.vue';
import { iterationSelectTextMap } from 'ee/sidebar/constants'; import { iterationSelectTextMap } from 'ee/sidebar/constants';
import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql'; import groupIterationsQuery from 'ee/sidebar/queries/iterations.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
const localVue = createLocalVue(); const localVue = createLocalVue();
......
import { GlDropdown, GlDropdownItem, GlDropdownText, GlLink, GlSearchBoxByType } from '@gitlab/ui'; import {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlLink,
GlSearchBoxByType,
GlFormInput,
GlLoadingIcon,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import IterationSelect from 'ee/sidebar/components/iteration_select.vue'; import SidebarIterationWidget from 'ee/sidebar/components/sidebar_iteration_widget.vue';
import { iterationSelectTextMap, iterationDisplayState } from 'ee/sidebar/constants'; import { iterationSelectTextMap, iterationDisplayState } from 'ee/sidebar/constants';
import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql'; import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql';
import currentIterationQuery from 'ee/sidebar/queries/issue_iteration.query.graphql'; import projectIssueIterationMutation from 'ee/sidebar/queries/project_issue_iteration.mutation.graphql';
import setIssueIterationMutation from 'ee/sidebar/queries/set_iteration_on_issue.mutation.graphql'; import projectIssueIterationQuery from 'ee/sidebar/queries/project_issue_iteration.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { import {
mockIssue, mockIssue,
mockIterationsResponse, mockGroupIterationsResponse,
mockIteration2, mockIteration2,
mockMutationResponse, mockMutationResponse,
emptyIterationsResponse, emptyGroupIterationsResponse,
noCurrentIterationResponse, noCurrentIterationResponse,
} from '../mock_data'; } from '../mock_data';
...@@ -26,16 +36,15 @@ jest.mock('~/flash'); ...@@ -26,16 +36,15 @@ jest.mock('~/flash');
const localVue = createLocalVue(); const localVue = createLocalVue();
describe('IterationSelect', () => { describe('SidebarIterationWidget', () => {
let wrapper; let wrapper;
let mockApollo; let mockApollo;
let showDropdown;
const promiseData = { issueSetIteration: { issue: { iteration: { id: '123' } } } }; const promiseData = { issuableSetIteration: { issue: { iteration: { id: '123' } } } };
const firstErrorMsg = 'first error'; const firstErrorMsg = 'first error';
const promiseWithErrors = { const promiseWithErrors = {
...promiseData, ...promiseData,
issueSetIteration: { ...promiseData.issueSetIteration, errors: [firstErrorMsg] }, issuableSetIteration: { ...promiseData.issuableSetIteration, errors: [firstErrorMsg] },
}; };
const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData }); const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData });
...@@ -50,65 +59,87 @@ describe('IterationSelect', () => { ...@@ -50,65 +59,87 @@ describe('IterationSelect', () => {
const findDropdownItemWithText = (text) => const findDropdownItemWithText = (text) =>
findAllDropdownItems().wrappers.find((x) => x.text() === text); findAllDropdownItems().wrappers.find((x) => x.text() === text);
const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findEditButton = () => findSidebarEditableItem().find('[data-testid="edit-button"]');
const findEditableLoadingIcon = () => findSidebarEditableItem().find(GlLoadingIcon);
const findIterationItems = () => wrapper.findByTestId('iteration-items'); const findIterationItems = () => wrapper.findByTestId('iteration-items');
const findSelectedIteration = () => wrapper.findByTestId('select-iteration'); const findSelectedIteration = () => wrapper.findByTestId('select-iteration');
const findNoIterationItem = () => wrapper.findByTestId('no-iteration-item'); const findNoIterationItem = () => wrapper.findByTestId('no-iteration-item');
const findLoadingIconTitle = () => wrapper.findByTestId('loading-icon-title');
const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown'); const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown');
const findEditButton = () => wrapper.findByTestId('iteration-edit-link');
const toggleDropdown = async (spy = () => {}) => {
findEditButton().vm.$emit('click', { stopPropagation: spy });
const waitForDropdown = async () => {
// BDropdown first changes its `visible` property
// in a requestAnimationFrame callback.
// It then emits `shown` event in a watcher for `visible`
// Hence we need both of these:
await waitForPromises();
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
}; };
const waitForApollo = async () => {
jest.runOnlyPendingTimers();
await waitForPromises();
};
// Used with createComponentWithApollo which uses 'mount'
const clickEdit = async () => {
await findEditButton().trigger('click');
await waitForDropdown();
// We should wait for iterations list to be fetched.
await waitForApollo();
};
// Used with createComponent which shallow mounts components
const toggleDropdown = async () => {
wrapper.vm.$refs.editable.expand();
await waitForDropdown();
};
const createComponentWithApollo = async ({ const createComponentWithApollo = async ({
props = { canEdit: true },
requestHandlers = [], requestHandlers = [],
currentIterationSpy = jest.fn().mockResolvedValue(noCurrentIterationResponse), currentIterationSpy = jest.fn().mockResolvedValue(noCurrentIterationResponse),
groupIterationsSpy = jest.fn().mockResolvedValue(mockIterationsResponse), groupIterationsSpy = jest.fn().mockResolvedValue(mockGroupIterationsResponse),
} = {}) => { } = {}) => {
localVue.use(VueApollo); localVue.use(VueApollo);
mockApollo = createMockApollo([ mockApollo = createMockApollo([
[currentIterationQuery, currentIterationSpy], [projectIssueIterationQuery, currentIterationSpy],
[groupIterationsQuery, groupIterationsSpy], [groupIterationsQuery, groupIterationsSpy],
...requestHandlers, ...requestHandlers,
]); ]);
wrapper = extendedWrapper( wrapper = extendedWrapper(
shallowMount(IterationSelect, { mount(SidebarIterationWidget, {
localVue, localVue,
provide: { canUpdate: true },
apolloProvider: mockApollo, apolloProvider: mockApollo,
propsData: { propsData: {
groupPath: mockIssue.groupPath, workspacePath: mockIssue.projectPath,
projectPath: mockIssue.projectPath, iterationsWorkspacePath: mockIssue.groupPath,
issueIid: mockIssue.iid, iid: mockIssue.iid,
...props, issuableType: IssuableType.Issue,
}, },
attachTo: document.body,
}), }),
); );
showDropdown = jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation(); await waitForApollo();
}; };
const createComponent = ({ const createComponent = ({ data = {}, mutationPromise = mutationSuccess, queries = {} } = {}) => {
data = {},
mutationPromise = mutationSuccess,
queries = {},
props = { canEdit: true },
stubs = { GlSearchBoxByType },
} = {}) => {
wrapper = extendedWrapper( wrapper = extendedWrapper(
shallowMount(IterationSelect, { shallowMount(SidebarIterationWidget, {
provide: { canUpdate: true },
data() { data() {
return data; return data;
}, },
propsData: { propsData: {
groupPath: '', workspacePath: '',
projectPath: '', iterationsWorkspacePath: '',
issueIid: '', iid: '',
...props, issuableType: IssuableType.Issue,
}, },
mocks: { mocks: {
$apollo: { $apollo: {
...@@ -120,11 +151,17 @@ describe('IterationSelect', () => { ...@@ -120,11 +151,17 @@ describe('IterationSelect', () => {
}, },
}, },
}, },
stubs, stubs: {
SidebarEditableItem,
GlSearchBoxByType,
GlDropdown,
},
}), }),
); );
showDropdown = jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation(); // We need to mock out `showDropdown` which
// invokes `show` method of BDropdown used inside GlDropdown.
jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
}; };
afterEach(() => { afterEach(() => {
...@@ -140,6 +177,7 @@ describe('IterationSelect', () => { ...@@ -140,6 +177,7 @@ describe('IterationSelect', () => {
}, },
stubs: { stubs: {
GlDropdown, GlDropdown,
SidebarEditableItem,
}, },
}); });
}); });
...@@ -153,7 +191,7 @@ describe('IterationSelect', () => { ...@@ -153,7 +191,7 @@ describe('IterationSelect', () => {
}); });
it('does not show a loading spinner next to the iteration heading', () => { it('does not show a loading spinner next to the iteration heading', () => {
expect(findLoadingIconTitle().exists()).toBe(false); expect(findEditableLoadingIcon().exists()).toBe(false);
}); });
it('shows a loading spinner while fetching the current iteration', () => { it('shows a loading spinner while fetching the current iteration', () => {
...@@ -161,12 +199,9 @@ describe('IterationSelect', () => { ...@@ -161,12 +199,9 @@ describe('IterationSelect', () => {
queries: { queries: {
currentIteration: { loading: true }, currentIteration: { loading: true },
}, },
stubs: {
GlDropdown,
},
}); });
expect(findLoadingIconTitle().exists()).toBe(true); expect(findEditableLoadingIcon().exists()).toBe(true);
}); });
it('shows the title of the selected iteration while updating', () => { it('shows the title of the selected iteration while updating', () => {
...@@ -178,77 +213,22 @@ describe('IterationSelect', () => { ...@@ -178,77 +213,22 @@ describe('IterationSelect', () => {
queries: { queries: {
currentIteration: { loading: false }, currentIteration: { loading: false },
}, },
stubs: {
GlDropdown,
},
}); });
expect(findLoadingIconTitle().exists()).toBe(true); expect(findEditableLoadingIcon().exists()).toBe(true);
expect(findSelectedIteration().text()).toBe('Some iteration title'); expect(findSelectedIteration().text()).toBe('Some iteration title');
}); });
describe('when current iteration does not exist', () => { describe('when current iteration does not exist', () => {
it('renders "None" as the selected iteration title', () => { it('renders "None" as the selected iteration title', () => {
createComponent({ createComponent();
stubs: {
GlDropdown,
},
});
expect(findSelectedIteration().text()).toBe('None'); expect(findSelectedIteration().text()).toBe('None');
}); });
}); });
}); });
describe('when a user cannot edit', () => {
it('cannot find the edit button', () => {
createComponent({
props: { canEdit: false },
stubs: {
GlDropdown,
},
});
expect(findEditButton().exists()).toBe(false);
});
});
describe('when a user can edit', () => { describe('when a user can edit', () => {
it('opens the dropdown on click of the edit button', async () => {
createComponent({ props: { canEdit: true } });
expect(findDropdown().isVisible()).toBe(false);
await toggleDropdown();
expect(findDropdown().isVisible()).toBe(true);
expect(showDropdown).toHaveBeenCalledTimes(1);
});
it('focuses on the input on click of the edit button', async () => {
createComponent({ props: { canEdit: true } });
const setFocus = jest.spyOn(wrapper.vm, 'setFocus').mockImplementation();
await toggleDropdown();
findDropdown().vm.$emit('shown');
await wrapper.vm.$nextTick();
expect(setFocus).toHaveBeenCalledTimes(1);
});
it('stops propagation of the click event to avoid opening milestone dropdown', async () => {
const spy = jest.fn();
createComponent({ props: { canEdit: true } });
expect(findDropdown().isVisible()).toBe(false);
await toggleDropdown(spy);
expect(spy).toHaveBeenCalledTimes(1);
});
describe('when user is editing', () => { describe('when user is editing', () => {
describe('when rendering the dropdown', () => { describe('when rendering the dropdown', () => {
it('shows a loading spinner while fetching a list of iterations', async () => { it('shows a loading spinner while fetching a list of iterations', async () => {
...@@ -359,7 +339,7 @@ describe('IterationSelect', () => { ...@@ -359,7 +339,7 @@ describe('IterationSelect', () => {
findDropdownItemWithText('title').vm.$emit('click'); findDropdownItemWithText('title').vm.$emit('click');
}); });
it('calls createFlash with $expectedMsg', async () => { it(`calls createFlash with "${expectedMsg}"`, async () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith(expectedMsg); expect(createFlash).toHaveBeenCalledWith(expectedMsg);
}); });
...@@ -384,77 +364,49 @@ describe('IterationSelect', () => { ...@@ -384,77 +364,49 @@ describe('IterationSelect', () => {
}); });
}); });
}); });
});
});
describe('when the user off clicks', () => { describe('with mock apollo', () => {
describe('when the dropdown is open', () => { let error;
beforeEach(async () => {
createComponent();
await toggleDropdown();
});
it('closes the dropdown', async () => { beforeEach(() => {
expect(findDropdown().isVisible()).toBe(true); jest.spyOn(Sentry, 'captureException');
error = new Error('mayday');
});
await toggleDropdown(); describe("when issuable type is 'issue'", () => {
describe('when dropdown is expanded and user can edit', () => {
let iterationMutationSpy;
beforeEach(async () => {
iterationMutationSpy = jest.fn().mockResolvedValue(mockMutationResponse);
expect(findDropdown().isVisible()).toBe(false); await createComponentWithApollo({
requestHandlers: [[projectIssueIterationMutation, iterationMutationSpy]],
}); });
});
});
// A user might press "ESC" to hide the dropdown.
// We need to make sure that
// toggleDropdown() gets called to set 'editing' to 'false'
describe('when the dropdown emits "hidden"', () => {
beforeEach(async () => {
createComponent();
await toggleDropdown(); await clickEdit();
}); });
it('should hide the dropdown', async () => { it('renders the dropdown on clicking edit', async () => {
expect(findDropdown().isVisible()).toBe(true); expect(findDropdown().isVisible()).toBe(true);
findDropdown().vm.$emit('hidden');
await wrapper.vm.$nextTick();
expect(findDropdown().isVisible()).toBe(false);
}); });
});
});
describe('With mock apollo', () => {
let error;
beforeEach(() => { it('focuses on the input when dropdown is shown', async () => {
jest.spyOn(Sentry, 'captureException'); expect(document.activeElement).toEqual(wrapper.find(GlFormInput).element);
error = new Error('mayday'); });
});
describe('when clicking on dropdown item', () => {
describe('when currentIteration is not equal to iteration id', () => { describe('when currentIteration is not equal to iteration id', () => {
let setIssueIterationSpy;
describe('when update is successful', () => { describe('when update is successful', () => {
setIssueIterationSpy = jest.fn().mockResolvedValue(mockMutationResponse); beforeEach(() => {
beforeEach(async () => {
createComponentWithApollo({
requestHandlers: [[setIssueIterationMutation, setIssueIterationSpy]],
});
await toggleDropdown();
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
findDropdownItemWithText(mockIteration2.title).vm.$emit('click'); findDropdownItemWithText(mockIteration2.title).vm.$emit('click');
}); });
it('calls setIssueIteration mutation', () => { it('calls setIssueIteration mutation', () => {
expect(setIssueIterationSpy).toHaveBeenCalledWith({ expect(iterationMutationSpy).toHaveBeenCalledWith({
iid: mockIssue.iid, iid: mockIssue.iid,
iterationId: mockIteration2.id, iterationId: mockIteration2.id,
projectPath: mockIssue.projectPath, fullPath: mockIssue.projectPath,
}); });
}); });
...@@ -463,81 +415,77 @@ describe('IterationSelect', () => { ...@@ -463,81 +415,77 @@ describe('IterationSelect', () => {
}); });
}); });
}); });
});
describe('currentIterations', () => { describe('iterations', () => {
it('should call createFlash and Sentry if currentIterations query fails', async () => { let groupIterationsSpy;
createComponentWithApollo({
currentIterationSpy: jest.fn().mockRejectedValue(error),
});
await waitForPromises(); it('should call createFlash and Sentry if iterations query fails', async () => {
await createComponentWithApollo({
expect(createFlash).toHaveBeenNthCalledWith(1, { groupIterationsSpy: jest.fn().mockRejectedValue(error),
message: wrapper.vm.$options.i18n.currentIterationFetchError, });
});
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
});
});
describe('iterations', () => { await clickEdit();
let groupIterationsSpy;
it('should call createFlash and Sentry if iterations query fails', async () => { expect(createFlash).toHaveBeenNthCalledWith(1, {
createComponentWithApollo({ message: wrapper.vm.$options.i18n.iterationsFetchError,
groupIterationsSpy: jest.fn().mockRejectedValue(error), });
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
}); });
await toggleDropdown(); it('only fetches iterations when dropdown is opened', async () => {
jest.runOnlyPendingTimers(); groupIterationsSpy = jest.fn().mockResolvedValueOnce(emptyGroupIterationsResponse);
await waitForPromises(); await createComponentWithApollo({ groupIterationsSpy });
expect(createFlash).toHaveBeenNthCalledWith(1, { expect(groupIterationsSpy).not.toHaveBeenCalled();
message: wrapper.vm.$options.i18n.iterationsFetchError,
});
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
});
it('only fetches iterations when dropdown is opened', async () => { await clickEdit();
groupIterationsSpy = jest.fn().mockResolvedValueOnce(emptyIterationsResponse);
createComponentWithApollo({ groupIterationsSpy });
await wrapper.vm.$nextTick(); expect(groupIterationsSpy).toHaveBeenNthCalledWith(1, {
jest.runOnlyPendingTimers(); fullPath: mockIssue.groupPath,
title: '',
state: iterationDisplayState,
});
});
expect(groupIterationsSpy).not.toHaveBeenCalled(); describe('when a user is searching', () => {
const mockSearchTerm = 'foobar';
await toggleDropdown(); beforeEach(async () => {
jest.runOnlyPendingTimers(); groupIterationsSpy = jest.fn().mockResolvedValueOnce(emptyGroupIterationsResponse);
await createComponentWithApollo({ groupIterationsSpy });
expect(groupIterationsSpy).toHaveBeenCalled(); await clickEdit();
}); });
describe('when a user is searching', () => { it('sends a groupIterations query with the entered search term "foo"', async () => {
const mockSearchTerm = 'foobar'; findSearchBox().vm.$emit('input', mockSearchTerm);
await wrapper.vm.$nextTick();
beforeEach(async () => { // Account for debouncing
groupIterationsSpy = jest.fn().mockResolvedValueOnce(emptyIterationsResponse); jest.runAllTimers();
createComponentWithApollo({ groupIterationsSpy });
await toggleDropdown(); expect(groupIterationsSpy).toHaveBeenNthCalledWith(2, {
fullPath: mockIssue.groupPath,
title: mockSearchTerm,
state: iterationDisplayState,
});
});
}); });
});
});
it('sends a groupIterations query with the entered search term "foo"', async () => { describe('currentIterations', () => {
findSearchBox().vm.$emit('input', mockSearchTerm); it('should call createFlash and Sentry if currentIterations query fails', async () => {
await createComponentWithApollo({
await wrapper.vm.$nextTick(); currentIterationSpy: jest.fn().mockRejectedValue(error),
jest.runOnlyPendingTimers(); });
expect(groupIterationsSpy).toHaveBeenNthCalledWith(1, { expect(createFlash).toHaveBeenNthCalledWith(1, {
fullPath: mockIssue.groupPath, message: wrapper.vm.$options.i18n.currentIterationFetchError,
title: `"${mockSearchTerm}"`,
state: iterationDisplayState,
});
}); });
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
}); });
}); });
}); });
}); });
}); });
//
...@@ -7,13 +7,6 @@ export const mockIssue = { ...@@ -7,13 +7,6 @@ export const mockIssue = {
groupPath: mockGroupPath, groupPath: mockGroupPath,
}; };
// This mock issue has a different format b/c
// it is used in board_sidebar_iteration_select_spec.js (swimlane sidebar)
export const mockIssue2 = {
referencePath: `${mockProjectPath}#1`,
iid: '1',
};
export const mockIssueId = 'gid://gitlab/Issue/1'; export const mockIssueId = 'gid://gitlab/Issue/1';
export const mockIteration1 = { export const mockIteration1 = {
...@@ -32,9 +25,9 @@ export const mockIteration2 = { ...@@ -32,9 +25,9 @@ export const mockIteration2 = {
state: 'opened', state: 'opened',
}; };
export const mockIterationsResponse = { export const mockGroupIterationsResponse = {
data: { data: {
group: { workspace: {
iterations: { iterations: {
nodes: [mockIteration1, mockIteration2], nodes: [mockIteration1, mockIteration2],
}, },
...@@ -44,9 +37,9 @@ export const mockIterationsResponse = { ...@@ -44,9 +37,9 @@ export const mockIterationsResponse = {
}, },
}; };
export const emptyIterationsResponse = { export const emptyGroupIterationsResponse = {
data: { data: {
group: { workspace: {
iterations: { iterations: {
nodes: [], nodes: [],
}, },
...@@ -58,8 +51,8 @@ export const emptyIterationsResponse = { ...@@ -58,8 +51,8 @@ export const emptyIterationsResponse = {
export const noCurrentIterationResponse = { export const noCurrentIterationResponse = {
data: { data: {
project: { workspace: {
issue: { id: mockIssueId, iteration: null, __typename: 'Issue' }, issuable: { id: mockIssueId, iteration: null, __typename: 'Issue' },
__typename: 'Project', __typename: 'Project',
}, },
}, },
...@@ -67,9 +60,9 @@ export const noCurrentIterationResponse = { ...@@ -67,9 +60,9 @@ export const noCurrentIterationResponse = {
export const mockMutationResponse = { export const mockMutationResponse = {
data: { data: {
issueSetIteration: { issuableSetIteration: {
errors: [], errors: [],
issue: { issuable: {
id: mockIssueId, id: mockIssueId,
iteration: { iteration: {
id: 'gid://gitlab/Iteration/2', id: 'gid://gitlab/Iteration/2',
......
...@@ -12,8 +12,11 @@ module QA ...@@ -12,8 +12,11 @@ module QA
super super
base.class_eval do base.class_eval do
view 'ee/app/assets/javascripts/sidebar/components/iteration_select.vue' do view 'app/assets/javascripts/sidebar/components/sidebar_editable_item.vue' do
element :edit_iteration_link element :edit_link
end
view 'ee/app/assets/javascripts/sidebar/components/sidebar_iteration_widget.vue' do
element :iteration_container element :iteration_container
element :iteration_link element :iteration_link
end end
...@@ -29,8 +32,8 @@ module QA ...@@ -29,8 +32,8 @@ module QA
end end
def assign_iteration(iteration) def assign_iteration(iteration)
click_element(:edit_iteration_link)
within_element(:iteration_container) do within_element(:iteration_container) do
click_element(:edit_link)
click_on("#{iteration.title}") click_on("#{iteration.title}")
end end
......
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