Commit e4b83504 authored by Eulyeon Ko's avatar Eulyeon Ko Committed by Natalia Tepluhina

Fix iteration select UI

- Make Iterations dropdown component to be consistent -
  with gitlab/ui.
- Do not fetch iterations list until user clicks edit.
- Replace out deprecatedFlash with createFlash
- Improve spec by using mock apollo client
parent 5caf8fc8
......@@ -156,14 +156,6 @@
color: inherit;
}
// TODO remove this class once we can generate a correct hover utility from `gitlab/ui`,
// see here: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39286#note_396767000
.btn-link-hover:hover {
* {
@include gl-text-blue-800;
}
}
.issuable-header-text {
margin-top: 7px;
}
......
......@@ -4,20 +4,34 @@ import {
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
GlDropdownSectionHeader,
GlDropdownDivider,
GlLoadingIcon,
GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import createFlash from '~/flash';
import * as Sentry from '~/sentry/wrapper';
import { __ } from '~/locale';
import groupIterationsQuery from '../queries/group_iterations.query.graphql';
import currentIterationQuery from '../queries/issue_iteration.query.graphql';
import setIssueIterationMutation from '../queries/set_iteration_on_issue.mutation.graphql';
import { iterationSelectTextMap, iterationDisplayState } from '../constants';
import { iterationSelectTextMap, iterationDisplayState, noIteration } from '../constants';
export default {
noIteration: iterationSelectTextMap.noIteration,
iterationText: iterationSelectTextMap.iteration,
noIteration,
i18n: {
iteration: iterationSelectTextMap.iteration,
noIteration: iterationSelectTextMap.noIteration,
assignIteration: iterationSelectTextMap.assignIteration,
iterationSelectFail: iterationSelectTextMap.iterationSelectFail,
noIterationsFound: iterationSelectTextMap.noIterationsFound,
currentIterationFetchError: iterationSelectTextMap.currentIterationFetchError,
iterationsFetchError: iterationSelectTextMap.iterationsFetchError,
edit: __('Edit'),
none: __('None'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
......@@ -26,9 +40,11 @@ export default {
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlDropdownDivider,
GlSearchBoxByType,
GlDropdownSectionHeader,
GlIcon,
GlLoadingIcon,
},
props: {
canEdit: {
......@@ -58,14 +74,20 @@ export default {
};
},
update(data) {
return data?.project?.issue?.iteration;
return data?.project?.issue.iteration;
},
error(error) {
createFlash({ message: this.$options.i18n.currentIterationFetchError });
Sentry.captureException(error);
},
},
iterations: {
query: groupIterationsQuery,
skip() {
return !this.editing;
},
debounce: 250,
variables() {
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/220381
const search = this.searchTerm === '' ? '' : `"${this.searchTerm}"`;
return {
......@@ -75,10 +97,11 @@ export default {
};
},
update(data) {
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/220379
const nodes = data.group?.iterations?.nodes || [];
return iterationSelectTextMap.noIterationItem.concat(nodes);
return data?.group?.iterations.nodes || [];
},
error(error) {
createFlash({ message: this.$options.i18n.iterationsFetchError });
Sentry.captureException(error);
},
},
},
......@@ -86,8 +109,10 @@ export default {
return {
searchTerm: '',
editing: false,
currentIteration: undefined,
iterations: iterationSelectTextMap.noIterationItem,
updating: false,
selectedTitle: null,
currentIteration: null,
iterations: [],
};
},
computed: {
......@@ -100,8 +125,17 @@ export default {
iterationUrl() {
return this.currentIteration?.webUrl;
},
dropdownText() {
return this.currentIteration ? this.currentIteration?.title : this.$options.i18n.iteration;
},
showNoIterationContent() {
return !this.editing && !this.currentIteration?.id;
return !this.updating && !this.currentIteration;
},
loading() {
return this.updating || this.$apollo.queries.currentIteration.loading;
},
noIterations() {
return this.iterations.length === 0;
},
},
mounted() {
......@@ -114,16 +148,18 @@ export default {
toggleDropdown() {
this.editing = !this.editing;
this.$nextTick(() => {
if (this.editing) {
this.$refs.search.focusInput();
}
});
if (this.editing) {
this.showDropdown();
}
},
setIteration(iterationId) {
this.editing = false;
if (iterationId === this.currentIteration?.id) return;
this.editing = false;
this.updating = true;
const selectedIteration = this.iterations.find((i) => i.id === iterationId);
this.selectedTitle = selectedIteration ? selectedIteration.title : this.$options.i18n.none;
this.$apollo
.mutate({
......@@ -143,6 +179,11 @@ export default {
const { iterationSelectFail } = iterationSelectTextMap;
createFlash(iterationSelectFail);
})
.finally(() => {
this.updating = false;
this.searchTerm = '';
this.selectedTitle = null;
});
},
handleOffClick(event) {
......@@ -157,6 +198,12 @@ export default {
iterationId === this.currentIteration?.id || (!this.currentIteration?.id && !iterationId)
);
},
showDropdown() {
this.$refs.newDropdown.show();
},
setFocus() {
this.$refs.search.focusInput();
},
},
};
</script>
......@@ -164,49 +211,79 @@ export default {
<template>
<div data-qa-selector="iteration_container">
<div v-gl-tooltip class="sidebar-collapsed-icon">
<gl-icon :size="16" :aria-label="$options.iterationText" name="iteration" />
<gl-icon :size="16" :aria-label="$options.i18n.iteration" name="iteration" />
<span class="collapse-truncated-title">{{ iterationTitle }}</span>
</div>
<div class="title hide-collapsed mt-3">
{{ $options.iterationText }}
<div class="hide-collapsed gl-mt-5">
{{ $options.i18n.iteration }}
<gl-loading-icon
v-if="loading"
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! btn-link-hover"
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"
>{{ __('Edit') }}</gl-button
>{{ $options.i18n.edit }}</gl-button
>
</div>
<div data-testid="select-iteration" class="hide-collapsed">
<span v-if="showNoIterationContent" class="no-value">{{ $options.noIteration }}</span>
<gl-link v-else-if="!editing" data-qa-selector="iteration_link" :href="iterationUrl"
<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"
:text="$options.iterationText"
class="dropdown gl-w-full"
:class="{ show: editing }"
lazy
:header-text="$options.i18n.assignIteration"
:text="dropdownText"
:loading="loading"
class="gl-w-full"
@shown="setFocus"
@hidden="toggleDropdown"
>
<gl-dropdown-section-header class="d-flex justify-content-center">{{
__('Assign Iteration')
}}</gl-dropdown-section-header>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
v-for="iterationItem in iterations"
:key="iterationItem.id"
data-testid="no-iteration-item"
:is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)"
@click="setIteration(iterationItem.id)"
>{{ iterationItem.title }}</gl-dropdown-item
: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>
</div>
</template>
......@@ -16,9 +16,15 @@ export const iterationSelectTextMap = {
iteration: __('Iteration'),
noIteration: __('No iteration'),
noIterationItem: [{ title: __('No iteration'), id: null }],
assignIteration: __('Assign Iteration'),
iterationSelectFail: __('Failed to set iteration on this issue. Please try again.'),
currentIterationFetchError: __('Failed to fetch the iteration for this issue. Please try again.'),
iterationsFetchError: __('Failed to fetch the iterations for the group. Please try again.'),
noIterationsFound: __('No iterations found'),
};
export const noIteration = null;
export const iterationDisplayState = 'opened';
export const healthStatusForRestApi = {
......
mutation updateIssueConfidential($projectPath: ID!, $iid: String!, $iterationId: ID) {
mutation setIssueIterationMutation($projectPath: ID!, $iid: String!, $iterationId: ID) {
issueSetIteration(input: { projectPath: $projectPath, iid: $iid, iterationId: $iterationId }) {
errors
issue {
......
---
title: Fixed iteration dropdown UI
merge_request: 52987
author:
type: fixed
......@@ -145,7 +145,7 @@ RSpec.describe 'Issue Sidebar' do
select_iteration('No iteration')
expect(page.find('[data-testid="select-iteration"]')).to have_content('No iteration')
expect(page.find('[data-testid="select-iteration"]')).to have_content('None')
end
it 'does not show closed iterations' do
......
import { GlDropdown, GlDropdownItem, GlButton, GlLink, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlDropdownText, GlLink, GlSearchBoxByType } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import IterationSelect from 'ee/sidebar/components/iteration_select.vue';
import { iterationSelectTextMap } from 'ee/sidebar/constants';
import setIterationOnIssue from 'ee/sidebar/queries/set_iteration_on_issue.mutation.graphql';
import { deprecatedCreateFlash as createFlash } from '~/flash';
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 createFlash from '~/flash';
import * as Sentry from '~/sentry/wrapper';
import {
mockIssue,
mockIterationsResponse,
mockIteration2,
mockMutationResponse,
emptyIterationsResponse,
noCurrentIterationResponse,
} from '../mock_data';
jest.mock('~/flash');
const localVue = createLocalVue();
describe('IterationSelect', () => {
let wrapper;
let mockApollo;
let showDropdown;
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 toggleDropdown = (spy = () => {}) =>
wrapper.find(GlButton).vm.$emit('click', { stopPropagation: spy });
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 findIterationItems = () => wrapper.findByTestId('iteration-items');
const findSelectedIteration = () => wrapper.findByTestId('select-iteration');
const findNoIterationItem = () => wrapper.findByTestId('no-iteration-item');
const findLoadingIconTitle = () => wrapper.findByTestId('loading-icon-title');
const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown');
const findEditButton = () => wrapper.findByTestId('iteration-edit-link');
const toggleDropdown = async (spy = () => {}) => {
findEditButton().vm.$emit('click', { stopPropagation: spy });
await wrapper.vm.$nextTick();
};
const createComponentWithApollo = async ({
props = { canEdit: true },
requestHandlers = [],
currentIterationSpy = jest.fn().mockResolvedValue(noCurrentIterationResponse),
groupIterationsSpy = jest.fn().mockResolvedValue(mockIterationsResponse),
} = {}) => {
localVue.use(VueApollo);
mockApollo = createMockApollo([
[currentIterationQuery, currentIterationSpy],
[groupIterationsQuery, groupIterationsSpy],
...requestHandlers,
]);
wrapper = extendedWrapper(
shallowMount(IterationSelect, {
localVue,
apolloProvider: mockApollo,
propsData: {
groupPath: mockIssue.groupPath,
projectPath: mockIssue.projectPath,
issueIid: mockIssue.iid,
...props,
},
}),
);
showDropdown = jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
};
const createComponent = ({
data = {},
mutationPromise = mutationSuccess,
queries = {},
props = { canEdit: true },
}) => {
wrapper = shallowMount(IterationSelect, {
data() {
return data;
},
propsData: {
...props,
groupPath: '',
projectPath: '',
issueIid: '',
},
mocks: {
$options: {
noIterationItem: [],
stubs = { GlSearchBoxByType },
} = {}) => {
wrapper = extendedWrapper(
shallowMount(IterationSelect, {
data() {
return data;
},
$apollo: {
mutate: mutationPromise(),
propsData: {
groupPath: '',
projectPath: '',
issueIid: '',
...props,
},
},
stubs: {
GlSearchBoxByType,
},
});
mocks: {
$apollo: {
mutate: mutationPromise(),
queries: {
currentIteration: { loading: false },
iterations: { loading: false },
...queries,
},
},
},
stubs,
}),
);
showDropdown = jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
};
afterEach(() => {
......@@ -56,34 +133,83 @@ describe('IterationSelect', () => {
});
describe('when not editing', () => {
it('shows the current iteration', () => {
beforeEach(() => {
createComponent({
data: {
iterations: [{ id: 'id', title: 'title' }],
currentIteration: { id: 'id', title: 'title' },
currentIteration: { id: 'id', title: 'title', webUrl: 'webUrl' },
},
stubs: {
GlDropdown,
},
});
});
expect(wrapper.find('[data-testid="select-iteration"]').text()).toBe('title');
it('shows the current iteration', () => {
expect(findSelectedIteration().text()).toBe('title');
});
it('links to the current iteration', () => {
expect(findGlLink().attributes().href).toBe('webUrl');
});
it('does not show a loading spinner next to the iteration heading', () => {
expect(findLoadingIconTitle().exists()).toBe(false);
});
it('shows a loading spinner while fetching the current iteration', () => {
createComponent({
queries: {
currentIteration: { loading: true },
},
stubs: {
GlDropdown,
},
});
expect(findLoadingIconTitle().exists()).toBe(true);
});
it('shows the title of the selected iteration while updating', () => {
createComponent({
data: {
iterations: [{ id: 'id', title: 'title', webUrl: 'webUrl' }],
currentIteration: { id: 'id', title: 'title', webUrl: 'webUrl' },
updating: true,
selectedTitle: 'Some iteration title',
},
queries: {
currentIteration: { loading: false },
},
stubs: {
GlDropdown,
},
});
expect(wrapper.find(GlLink).attributes().href).toBe('webUrl');
expect(findLoadingIconTitle().exists()).toBe(true);
expect(findSelectedIteration().text()).toBe('Some iteration title');
});
describe('when current iteration does not exist', () => {
it('renders "None" as the selected iteration title', () => {
createComponent({
stubs: {
GlDropdown,
},
});
expect(findSelectedIteration().text()).toBe('None');
});
});
});
describe('when a user cannot edit', () => {
it('cannot find the edit button', () => {
createComponent({ props: { canEdit: false } });
createComponent({
props: { canEdit: false },
stubs: {
GlDropdown,
},
});
expect(wrapper.find(GlButton).exists()).toBe(false);
expect(findEditButton().exists()).toBe(false);
});
});
......@@ -91,69 +217,75 @@ describe('IterationSelect', () => {
it('opens the dropdown on click of the edit button', async () => {
createComponent({ props: { canEdit: true } });
expect(wrapper.find(GlDropdown).isVisible()).toBe(false);
expect(findDropdown().isVisible()).toBe(false);
toggleDropdown();
await toggleDropdown();
await wrapper.vm.$nextTick();
expect(wrapper.find(GlDropdown).isVisible()).toBe(true);
expect(findDropdown().isVisible()).toBe(true);
expect(showDropdown).toHaveBeenCalledTimes(1);
});
it('focuses on the input', async () => {
it('focuses on the input on click of the edit button', async () => {
createComponent({ props: { canEdit: true } });
const setFocus = jest.spyOn(wrapper.vm, 'setFocus').mockImplementation();
const spy = jest.spyOn(wrapper.vm.$refs.search, 'focusInput');
await toggleDropdown();
toggleDropdown();
findDropdown().vm.$emit('shown');
await wrapper.vm.$nextTick();
expect(spy).toHaveBeenCalled();
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(wrapper.find(GlDropdown).isVisible()).toBe(false);
expect(findDropdown().isVisible()).toBe(false);
toggleDropdown(spy);
await toggleDropdown(spy);
await wrapper.vm.$nextTick();
expect(spy).toHaveBeenCalledTimes(1);
});
describe('when user is editing', () => {
describe('when rendering the dropdown', () => {
it('shows GlDropdown', () => {
createComponent({ props: { canEdit: true }, data: { editing: true } });
it('shows a loading spinner while fetching a list of iterations', async () => {
createComponent({
queries: {
iterations: { loading: true },
},
});
expect(wrapper.find(GlDropdown).isVisible()).toBe(true);
await toggleDropdown();
expect(findLoadingIconDropdown().exists()).toBe(true);
});
describe('GlDropdownItem with the right title and id', () => {
const id = 'id';
const title = 'title';
beforeEach(() => {
beforeEach(async () => {
createComponent({
data: { iterations: [{ id, title }], currentIteration: { id, title } },
});
await toggleDropdown();
});
it('does not show a loading spinner', () => {
expect(findLoadingIconDropdown().exists()).toBe(false);
});
it('renders title $title', () => {
expect(
wrapper
.findAll(GlDropdownItem)
.filter((w) => w.text() === title)
.at(0)
.text(),
).toBe(title);
expect(findDropdownItemWithText(title).text()).toBe(title);
});
it('checks the correct dropdown item', () => {
expect(
wrapper
.findAll(GlDropdownItem)
findAllDropdownItems()
.filter((w) => w.props('isChecked') === true)
.at(0)
.text(),
......@@ -162,22 +294,28 @@ describe('IterationSelect', () => {
});
describe('when no data is assigned', () => {
beforeEach(() => {
createComponent({});
beforeEach(async () => {
createComponent();
await toggleDropdown();
});
it('finds GlDropdownItem with "No iteration"', () => {
expect(wrapper.find(GlDropdownItem).text()).toBe('No iteration');
expect(findNoIterationItem().text()).toBe('No iteration');
});
it('"No iteration" is checked', () => {
expect(wrapper.find(GlDropdownItem).props('isChecked')).toBe(true);
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', () => {
it('does not call setIssueIteration mutation', async () => {
createComponent({
data: {
iterations: [{ id: 'id', title: 'title' }],
......@@ -185,49 +323,15 @@ describe('IterationSelect', () => {
},
});
wrapper
.findAll(GlDropdownItem)
.filter((w) => w.text() === 'title')
.at(0)
.vm.$emit('click');
await toggleDropdown();
findDropdownItemWithText('title').vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0);
});
});
describe('when currentIteration is not equal to iteration id', () => {
describe('when success', () => {
beforeEach(() => {
createComponent({
data: {
iterations: [
{ id: 'id', title: 'title' },
{ id: '123', title: '123' },
],
currentIteration: '123',
},
});
wrapper
.findAll(GlDropdownItem)
.filter((w) => w.text() === 'title')
.at(0)
.vm.$emit('click');
});
it('calls setIssueIteration mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: setIterationOnIssue,
variables: { projectPath: '', iterationId: 'id', iid: '' },
});
});
it('sets the value returned from the mutation to currentIteration', async () => {
await wrapper.vm.$nextTick();
expect(wrapper.vm.currentIteration).toBe('123');
});
});
describe('when error', () => {
const bootstrapComponent = (mutationResp) => {
createComponent({
......@@ -247,14 +351,12 @@ describe('IterationSelect', () => {
${'top-level error'} | ${mutationError} | ${iterationSelectTextMap.iterationSelectFail}
${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg}
`(`$description`, ({ mutationResp, expectedMsg }) => {
beforeEach(() => {
beforeEach(async () => {
bootstrapComponent(mutationResp);
wrapper
.findAll(GlDropdownItem)
.filter((w) => w.text() === 'title')
.at(0)
.vm.$emit('click');
await toggleDropdown();
findDropdownItemWithText('title').vm.$emit('click');
});
it('calls createFlash with $expectedMsg', async () => {
......@@ -268,108 +370,169 @@ describe('IterationSelect', () => {
});
describe('when a user is searching', () => {
beforeEach(() => {
createComponent({});
});
describe('when search result is not found', () => {
it('renders "No iterations found"', async () => {
createComponent();
it('sets the search term', async () => {
wrapper.find(GlSearchBoxByType).vm.$emit('input', 'testing');
await toggleDropdown();
await wrapper.vm.$nextTick();
expect(wrapper.vm.searchTerm).toBe('testing');
findSearchBox().vm.$emit('input', 'non existing iterations');
await wrapper.vm.$nextTick();
expect(findDropdownText().text()).toBe('No iterations found');
});
});
});
describe('when the user off clicks', () => {
describe('when the dropdown is open', () => {
beforeEach(async () => {
createComponent({});
createComponent();
toggleDropdown();
await wrapper.vm.$nextTick();
await toggleDropdown();
});
it('closes the dropdown', async () => {
expect(wrapper.find(GlDropdown).isVisible()).toBe(true);
expect(findDropdown().isVisible()).toBe(true);
toggleDropdown();
await toggleDropdown();
await wrapper.vm.$nextTick();
expect(wrapper.find(GlDropdown).isVisible()).toBe(false);
expect(findDropdown().isVisible()).toBe(false);
});
});
});
});
describe('apollo schema', () => {
describe('iterations', () => {
describe('when iterations is passed the wrong data object', () => {
beforeEach(() => {
createComponent({});
});
// 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();
it.each([
[{}, iterationSelectTextMap.noIterationItem],
[{ group: {} }, iterationSelectTextMap.noIterationItem],
[{ group: { iterations: {} } }, iterationSelectTextMap.noIterationItem],
[
{ group: { iterations: { nodes: ['nodes'] } } },
[...iterationSelectTextMap.noIterationItem, 'nodes'],
],
])('when %j as an argument it returns %j', (data, value) => {
const { update } = wrapper.vm.$options.apollo.iterations;
expect(update(data)).toEqual(value);
});
await toggleDropdown();
});
it('should hide the dropdown', async () => {
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(() => {
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]],
});
it('contains debounce', () => {
createComponent({});
await toggleDropdown();
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
const { debounce } = wrapper.vm.$options.apollo.iterations;
findDropdownItemWithText(mockIteration2.title).vm.$emit('click');
});
it('calls setIssueIteration mutation', () => {
expect(setIssueIterationSpy).toHaveBeenCalledWith({
iid: mockIssue.iid,
iterationId: mockIteration2.id,
projectPath: mockIssue.projectPath,
});
});
expect(debounce).toBe(250);
it('sets the value returned from the mutation to currentIteration', async () => {
expect(findSelectedIteration().text()).toBe(mockIteration2.title);
});
});
});
});
it('returns the correct values based on the schema', () => {
createComponent({});
describe('currentIterations', () => {
it('should call createFlash and Sentry if currentIterations query fails', async () => {
createComponentWithApollo({
currentIterationSpy: jest.fn().mockRejectedValue(error),
});
const { update } = wrapper.vm.$options.apollo.iterations;
// needed to access this.$options in update
const boundUpdate = update.bind(wrapper.vm);
await waitForPromises();
expect(boundUpdate({ group: { iterations: { nodes: [] } } })).toEqual(
iterationSelectTextMap.noIterationItem,
);
expect(createFlash).toHaveBeenNthCalledWith(1, {
message: wrapper.vm.$options.i18n.currentIterationFetchError,
});
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
});
});
describe('currentIteration', () => {
describe('when passes an object that doesnt contain the correct values', () => {
beforeEach(() => {
createComponent({});
describe('iterations', () => {
let groupIterationsSpy;
it('should call createFlash and Sentry if iterations query fails', async () => {
createComponentWithApollo({
groupIterationsSpy: jest.fn().mockRejectedValue(error),
});
it.each([
[{}, undefined],
[{ project: { issue: {} } }, undefined],
[{ project: { issue: { iteration: {} } } }, {}],
])('when %j as an argument it returns %j', (data, value) => {
const { update } = wrapper.vm.$options.apollo.currentIteration;
await toggleDropdown();
jest.runOnlyPendingTimers();
await waitForPromises();
expect(update(data)).toEqual(value);
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 toggleDropdown();
jest.runOnlyPendingTimers();
expect(groupIterationsSpy).toHaveBeenCalled();
});
describe('when iteration has an id', () => {
it('returns the id', () => {
createComponent({});
describe('when a user is searching', () => {
const mockSearchTerm = 'foobar';
beforeEach(async () => {
groupIterationsSpy = jest.fn().mockResolvedValueOnce(emptyIterationsResponse);
createComponentWithApollo({ groupIterationsSpy });
await toggleDropdown();
});
it('sends a groupIterations query with the entered search term "foo"', async () => {
findSearchBox().vm.$emit('input', mockSearchTerm);
const { update } = wrapper.vm.$options.apollo.currentIteration;
await wrapper.vm.$nextTick();
jest.runOnlyPendingTimers();
expect(update({ project: { issue: { iteration: { id: '123' } } } })).toEqual({
id: '123',
expect(groupIterationsSpy).toHaveBeenNthCalledWith(1, {
fullPath: mockIssue.groupPath,
title: `"${mockSearchTerm}"`,
state: iterationDisplayState,
});
});
});
......@@ -377,3 +540,4 @@ describe('IterationSelect', () => {
});
});
});
//
export const mockIssue = {
projectPath: 'gitlab-org/some-project',
iid: '1',
groupPath: 'gitlab-org',
};
export const mockIssueId = 'gid://gitlab/Issue/1';
export const mockIteration1 = {
__typename: 'Iteration',
id: 'gid://gitlab/Iteration/1',
title: 'Foobar Iteration',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/iterations/1',
state: 'opened',
};
export const mockIteration2 = {
__typename: 'Iteration',
id: 'gid://gitlab/Iteration/2',
title: 'Awesome Iteration',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/iterations/2',
state: 'opened',
};
export const mockIterationsResponse = {
data: {
group: {
iterations: {
nodes: [mockIteration1, mockIteration2],
},
__typename: 'IterationConnection',
},
__typename: 'Group',
},
};
export const emptyIterationsResponse = {
data: {
group: {
iterations: {
nodes: [],
},
__typename: 'IterationConnection',
},
__typename: 'Group',
},
};
export const noCurrentIterationResponse = {
data: {
project: {
issue: { id: mockIssueId, iteration: null, __typename: 'Issue' },
__typename: 'Project',
},
},
};
export const mockMutationResponse = {
data: {
issueSetIteration: {
errors: [],
issue: {
id: mockIssueId,
iteration: {
id: 'gid://gitlab/Iteration/2',
title: 'Awesome Iteration',
state: 'opened',
__typename: 'Iteration',
},
__typename: 'Issue',
},
__typename: 'IssueSetIterationPayload',
},
},
};
......@@ -12080,6 +12080,12 @@ msgstr ""
msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later."
msgstr ""
msgid "Failed to fetch the iteration for this issue. Please try again."
msgstr ""
msgid "Failed to fetch the iterations for the group. Please try again."
msgstr ""
msgid "Failed to find import label for Jira import."
msgstr ""
......@@ -19842,6 +19848,9 @@ msgstr ""
msgid "No iteration"
msgstr ""
msgid "No iterations found"
msgstr ""
msgid "No iterations to show"
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