Commit 342f400a authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'fix-iteration-select-ui' into 'master'

Resolve "Iterations dropdown component is not consistent with gitlab/ui"

See merge request gitlab-org/gitlab!52987
parents 5c2fd018 e4b83504
...@@ -156,14 +156,6 @@ ...@@ -156,14 +156,6 @@
color: inherit; 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 { .issuable-header-text {
margin-top: 7px; margin-top: 7px;
} }
......
...@@ -4,20 +4,34 @@ import { ...@@ -4,20 +4,34 @@ import {
GlLink, GlLink,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownText,
GlSearchBoxByType, GlSearchBoxByType,
GlDropdownSectionHeader, GlDropdownDivider,
GlLoadingIcon,
GlIcon, GlIcon,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } 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 groupIterationsQuery from '../queries/group_iterations.query.graphql';
import currentIterationQuery from '../queries/issue_iteration.query.graphql'; import currentIterationQuery from '../queries/issue_iteration.query.graphql';
import setIssueIterationMutation from '../queries/set_iteration_on_issue.mutation.graphql'; import setIssueIterationMutation from '../queries/set_iteration_on_issue.mutation.graphql';
import { iterationSelectTextMap, iterationDisplayState } from '../constants'; import { iterationSelectTextMap, iterationDisplayState, noIteration } from '../constants';
export default { export default {
noIteration: iterationSelectTextMap.noIteration, noIteration,
iterationText: iterationSelectTextMap.iteration, 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: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
...@@ -26,9 +40,11 @@ export default { ...@@ -26,9 +40,11 @@ export default {
GlLink, GlLink,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownText,
GlDropdownDivider,
GlSearchBoxByType, GlSearchBoxByType,
GlDropdownSectionHeader,
GlIcon, GlIcon,
GlLoadingIcon,
}, },
props: { props: {
canEdit: { canEdit: {
...@@ -58,14 +74,20 @@ export default { ...@@ -58,14 +74,20 @@ export default {
}; };
}, },
update(data) { update(data) {
return data?.project?.issue?.iteration; return data?.project?.issue.iteration;
},
error(error) {
createFlash({ message: this.$options.i18n.currentIterationFetchError });
Sentry.captureException(error);
}, },
}, },
iterations: { iterations: {
query: groupIterationsQuery, query: groupIterationsQuery,
skip() {
return !this.editing;
},
debounce: 250, debounce: 250,
variables() { variables() {
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/220381
const search = this.searchTerm === '' ? '' : `"${this.searchTerm}"`; const search = this.searchTerm === '' ? '' : `"${this.searchTerm}"`;
return { return {
...@@ -75,10 +97,11 @@ export default { ...@@ -75,10 +97,11 @@ export default {
}; };
}, },
update(data) { update(data) {
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/220379 return data?.group?.iterations.nodes || [];
const nodes = data.group?.iterations?.nodes || []; },
error(error) {
return iterationSelectTextMap.noIterationItem.concat(nodes); createFlash({ message: this.$options.i18n.iterationsFetchError });
Sentry.captureException(error);
}, },
}, },
}, },
...@@ -86,8 +109,10 @@ export default { ...@@ -86,8 +109,10 @@ export default {
return { return {
searchTerm: '', searchTerm: '',
editing: false, editing: false,
currentIteration: undefined, updating: false,
iterations: iterationSelectTextMap.noIterationItem, selectedTitle: null,
currentIteration: null,
iterations: [],
}; };
}, },
computed: { computed: {
...@@ -100,8 +125,17 @@ export default { ...@@ -100,8 +125,17 @@ export default {
iterationUrl() { iterationUrl() {
return this.currentIteration?.webUrl; return this.currentIteration?.webUrl;
}, },
dropdownText() {
return this.currentIteration ? this.currentIteration?.title : this.$options.i18n.iteration;
},
showNoIterationContent() { 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() { mounted() {
...@@ -114,16 +148,18 @@ export default { ...@@ -114,16 +148,18 @@ export default {
toggleDropdown() { toggleDropdown() {
this.editing = !this.editing; this.editing = !this.editing;
this.$nextTick(() => { if (this.editing) {
if (this.editing) { this.showDropdown();
this.$refs.search.focusInput(); }
}
});
}, },
setIteration(iterationId) { setIteration(iterationId) {
this.editing = false;
if (iterationId === this.currentIteration?.id) return; 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 this.$apollo
.mutate({ .mutate({
...@@ -143,6 +179,11 @@ export default { ...@@ -143,6 +179,11 @@ export default {
const { iterationSelectFail } = iterationSelectTextMap; const { iterationSelectFail } = iterationSelectTextMap;
createFlash(iterationSelectFail); createFlash(iterationSelectFail);
})
.finally(() => {
this.updating = false;
this.searchTerm = '';
this.selectedTitle = null;
}); });
}, },
handleOffClick(event) { handleOffClick(event) {
...@@ -157,6 +198,12 @@ export default { ...@@ -157,6 +198,12 @@ export default {
iterationId === this.currentIteration?.id || (!this.currentIteration?.id && !iterationId) iterationId === this.currentIteration?.id || (!this.currentIteration?.id && !iterationId)
); );
}, },
showDropdown() {
this.$refs.newDropdown.show();
},
setFocus() {
this.$refs.search.focusInput();
},
}, },
}; };
</script> </script>
...@@ -164,49 +211,79 @@ export default { ...@@ -164,49 +211,79 @@ export default {
<template> <template>
<div data-qa-selector="iteration_container"> <div data-qa-selector="iteration_container">
<div v-gl-tooltip class="sidebar-collapsed-icon"> <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> <span class="collapse-truncated-title">{{ iterationTitle }}</span>
</div> </div>
<div class="title hide-collapsed mt-3"> <div class="hide-collapsed gl-mt-5">
{{ $options.iterationText }} {{ $options.i18n.iteration }}
<gl-loading-icon
v-if="loading"
class="gl-ml-2"
:inline="true"
data-testid="loading-icon-title"
/>
<gl-button <gl-button
v-if="canEdit" v-if="canEdit"
variant="link" 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-testid="iteration-edit-link"
data-track-label="right_sidebar" data-track-label="right_sidebar"
data-track-property="iteration" data-track-property="iteration"
data-track-event="click_edit_button" data-track-event="click_edit_button"
data-qa-selector="edit_iteration_link" data-qa-selector="edit_iteration_link"
@click.stop="toggleDropdown" @click.stop="toggleDropdown"
>{{ __('Edit') }}</gl-button >{{ $options.i18n.edit }}</gl-button
> >
</div> </div>
<div data-testid="select-iteration" class="hide-collapsed"> <div v-if="!editing" data-testid="select-iteration" class="hide-collapsed">
<span v-if="showNoIterationContent" class="no-value">{{ $options.noIteration }}</span> <strong v-if="updating">{{ selectedTitle }}</strong>
<gl-link v-else-if="!editing" data-qa-selector="iteration_link" :href="iterationUrl" <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 ><strong>{{ iterationTitle }}</strong></gl-link
> >
</div> </div>
<gl-dropdown <gl-dropdown
v-show="editing" v-show="editing"
ref="newDropdown" ref="newDropdown"
:text="$options.iterationText" lazy
class="dropdown gl-w-full" :header-text="$options.i18n.assignIteration"
:class="{ show: editing }" :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-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item <gl-dropdown-item
v-for="iterationItem in iterations" data-testid="no-iteration-item"
:key="iterationItem.id"
:is-check-item="true" :is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)" :is-checked="isIterationChecked($options.noIteration)"
@click="setIteration(iterationItem.id)" @click="setIteration($options.noIteration)"
>{{ iterationItem.title }}</gl-dropdown-item
> >
{{ $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> </gl-dropdown>
</div> </div>
</template> </template>
...@@ -16,9 +16,15 @@ export const iterationSelectTextMap = { ...@@ -16,9 +16,15 @@ export const iterationSelectTextMap = {
iteration: __('Iteration'), iteration: __('Iteration'),
noIteration: __('No iteration'), noIteration: __('No iteration'),
noIterationItem: [{ title: __('No iteration'), id: null }], noIterationItem: [{ title: __('No iteration'), id: null }],
assignIteration: __('Assign Iteration'),
iterationSelectFail: __('Failed to set iteration on this issue. Please try again.'), 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 iterationDisplayState = 'opened';
export const healthStatusForRestApi = { 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 }) { issueSetIteration(input: { projectPath: $projectPath, iid: $iid, iterationId: $iterationId }) {
errors errors
issue { issue {
......
---
title: Fixed iteration dropdown UI
merge_request: 52987
author:
type: fixed
...@@ -145,7 +145,7 @@ RSpec.describe 'Issue Sidebar' do ...@@ -145,7 +145,7 @@ RSpec.describe 'Issue Sidebar' do
select_iteration('No iteration') 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 end
it 'does not show closed iterations' do it 'does not show closed iterations' do
......
import { GlDropdown, GlDropdownItem, GlButton, GlLink, GlSearchBoxByType } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem, GlDropdownText, GlLink, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; 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 IterationSelect from 'ee/sidebar/components/iteration_select.vue';
import { iterationSelectTextMap } from 'ee/sidebar/constants'; import { iterationSelectTextMap, iterationDisplayState } from 'ee/sidebar/constants';
import setIterationOnIssue from 'ee/sidebar/queries/set_iteration_on_issue.mutation.graphql'; import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql';
import { deprecatedCreateFlash as createFlash } from '~/flash'; 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'); jest.mock('~/flash');
const localVue = createLocalVue();
describe('IterationSelect', () => { describe('IterationSelect', () => {
let wrapper; let wrapper;
let mockApollo;
let showDropdown;
const promiseData = { issueSetIteration: { issue: { iteration: { id: '123' } } } }; const promiseData = { issueSetIteration: { issue: { iteration: { id: '123' } } } };
const firstErrorMsg = 'first error'; const firstErrorMsg = 'first error';
const promiseWithErrors = { const promiseWithErrors = {
...promiseData, ...promiseData,
issueSetIteration: { ...promiseData.issueSetIteration, errors: [firstErrorMsg] }, issueSetIteration: { ...promiseData.issueSetIteration, errors: [firstErrorMsg] },
}; };
const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData }); const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData });
const mutationError = () => jest.fn().mockRejectedValue(); const mutationError = () => jest.fn().mockRejectedValue();
const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors }); 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 = ({ const createComponent = ({
data = {}, data = {},
mutationPromise = mutationSuccess, mutationPromise = mutationSuccess,
queries = {},
props = { canEdit: true }, props = { canEdit: true },
}) => { stubs = { GlSearchBoxByType },
wrapper = shallowMount(IterationSelect, { } = {}) => {
data() { wrapper = extendedWrapper(
return data; shallowMount(IterationSelect, {
}, data() {
propsData: { return data;
...props,
groupPath: '',
projectPath: '',
issueIid: '',
},
mocks: {
$options: {
noIterationItem: [],
}, },
$apollo: { propsData: {
mutate: mutationPromise(), groupPath: '',
projectPath: '',
issueIid: '',
...props,
}, },
}, mocks: {
stubs: { $apollo: {
GlSearchBoxByType, mutate: mutationPromise(),
}, queries: {
}); currentIteration: { loading: false },
iterations: { loading: false },
...queries,
},
},
},
stubs,
}),
);
showDropdown = jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
}; };
afterEach(() => { afterEach(() => {
...@@ -56,34 +133,83 @@ describe('IterationSelect', () => { ...@@ -56,34 +133,83 @@ describe('IterationSelect', () => {
}); });
describe('when not editing', () => { describe('when not editing', () => {
it('shows the current iteration', () => { beforeEach(() => {
createComponent({ createComponent({
data: { data: {
iterations: [{ id: 'id', title: 'title' }], currentIteration: { id: 'id', title: 'title', webUrl: 'webUrl' },
currentIteration: { id: 'id', title: 'title' }, },
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', () => { 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({ createComponent({
data: { data: {
iterations: [{ id: 'id', title: 'title', webUrl: 'webUrl' }], updating: true,
currentIteration: { id: 'id', title: 'title', webUrl: 'webUrl' }, 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', () => { describe('when a user cannot edit', () => {
it('cannot find the edit button', () => { 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', () => { ...@@ -91,69 +217,75 @@ describe('IterationSelect', () => {
it('opens the dropdown on click of the edit button', async () => { it('opens the dropdown on click of the edit button', async () => {
createComponent({ props: { canEdit: true } }); createComponent({ props: { canEdit: true } });
expect(wrapper.find(GlDropdown).isVisible()).toBe(false); expect(findDropdown().isVisible()).toBe(false);
toggleDropdown(); await toggleDropdown();
await wrapper.vm.$nextTick(); expect(findDropdown().isVisible()).toBe(true);
expect(wrapper.find(GlDropdown).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 } }); 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(); await wrapper.vm.$nextTick();
expect(spy).toHaveBeenCalled();
expect(setFocus).toHaveBeenCalledTimes(1);
}); });
it('stops propagation of the click event to avoid opening milestone dropdown', async () => { it('stops propagation of the click event to avoid opening milestone dropdown', async () => {
const spy = jest.fn(); const spy = jest.fn();
createComponent({ props: { canEdit: true } }); 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); 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 GlDropdown', () => { it('shows a loading spinner while fetching a list of iterations', async () => {
createComponent({ props: { canEdit: true }, data: { editing: true } }); 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', () => { describe('GlDropdownItem with the right title and id', () => {
const id = 'id'; const id = 'id';
const title = 'title'; const title = 'title';
beforeEach(() => { beforeEach(async () => {
createComponent({ createComponent({
data: { iterations: [{ id, title }], currentIteration: { id, title } }, 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', () => { it('renders title $title', () => {
expect( expect(findDropdownItemWithText(title).text()).toBe(title);
wrapper
.findAll(GlDropdownItem)
.filter((w) => w.text() === title)
.at(0)
.text(),
).toBe(title);
}); });
it('checks the correct dropdown item', () => { it('checks the correct dropdown item', () => {
expect( expect(
wrapper findAllDropdownItems()
.findAll(GlDropdownItem)
.filter((w) => w.props('isChecked') === true) .filter((w) => w.props('isChecked') === true)
.at(0) .at(0)
.text(), .text(),
...@@ -162,22 +294,28 @@ describe('IterationSelect', () => { ...@@ -162,22 +294,28 @@ describe('IterationSelect', () => {
}); });
describe('when no data is assigned', () => { describe('when no data is assigned', () => {
beforeEach(() => { beforeEach(async () => {
createComponent({}); createComponent();
await toggleDropdown();
}); });
it('finds GlDropdownItem with "No iteration"', () => { 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', () => { 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 clicking on dropdown item', () => {
describe('when currentIteration is equal to iteration id', () => { describe('when currentIteration is equal to iteration id', () => {
it('does not call setIssueIteration mutation', () => { it('does not call setIssueIteration mutation', async () => {
createComponent({ createComponent({
data: { data: {
iterations: [{ id: 'id', title: 'title' }], iterations: [{ id: 'id', title: 'title' }],
...@@ -185,49 +323,15 @@ describe('IterationSelect', () => { ...@@ -185,49 +323,15 @@ describe('IterationSelect', () => {
}, },
}); });
wrapper await toggleDropdown();
.findAll(GlDropdownItem)
.filter((w) => w.text() === 'title') findDropdownItemWithText('title').vm.$emit('click');
.at(0)
.vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0);
}); });
}); });
describe('when currentIteration is not equal to iteration id', () => { 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', () => { describe('when error', () => {
const bootstrapComponent = (mutationResp) => { const bootstrapComponent = (mutationResp) => {
createComponent({ createComponent({
...@@ -247,14 +351,12 @@ describe('IterationSelect', () => { ...@@ -247,14 +351,12 @@ describe('IterationSelect', () => {
${'top-level error'} | ${mutationError} | ${iterationSelectTextMap.iterationSelectFail} ${'top-level error'} | ${mutationError} | ${iterationSelectTextMap.iterationSelectFail}
${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg} ${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg}
`(`$description`, ({ mutationResp, expectedMsg }) => { `(`$description`, ({ mutationResp, expectedMsg }) => {
beforeEach(() => { beforeEach(async () => {
bootstrapComponent(mutationResp); bootstrapComponent(mutationResp);
wrapper await toggleDropdown();
.findAll(GlDropdownItem)
.filter((w) => w.text() === 'title') findDropdownItemWithText('title').vm.$emit('click');
.at(0)
.vm.$emit('click');
}); });
it('calls createFlash with $expectedMsg', async () => { it('calls createFlash with $expectedMsg', async () => {
...@@ -268,108 +370,169 @@ describe('IterationSelect', () => { ...@@ -268,108 +370,169 @@ describe('IterationSelect', () => {
}); });
describe('when a user is searching', () => { describe('when a user is searching', () => {
beforeEach(() => { describe('when search result is not found', () => {
createComponent({}); it('renders "No iterations found"', async () => {
}); createComponent();
it('sets the search term', async () => { await toggleDropdown();
wrapper.find(GlSearchBoxByType).vm.$emit('input', 'testing');
await wrapper.vm.$nextTick(); findSearchBox().vm.$emit('input', 'non existing iterations');
expect(wrapper.vm.searchTerm).toBe('testing');
await wrapper.vm.$nextTick();
expect(findDropdownText().text()).toBe('No iterations found');
});
}); });
}); });
describe('when the user off clicks', () => { describe('when the user off clicks', () => {
describe('when the dropdown is open', () => { describe('when the dropdown is open', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent({}); createComponent();
toggleDropdown(); await toggleDropdown();
await wrapper.vm.$nextTick();
}); });
it('closes the dropdown', async () => { 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(findDropdown().isVisible()).toBe(false);
expect(wrapper.find(GlDropdown).isVisible()).toBe(false);
}); });
}); });
}); });
});
describe('apollo schema', () => { // A user might press "ESC" to hide the dropdown.
describe('iterations', () => { // We need to make sure that
describe('when iterations is passed the wrong data object', () => { // toggleDropdown() gets called to set 'editing' to 'false'
beforeEach(() => { describe('when the dropdown emits "hidden"', () => {
createComponent({}); beforeEach(async () => {
}); createComponent();
it.each([ await toggleDropdown();
[{}, iterationSelectTextMap.noIterationItem], });
[{ group: {} }, iterationSelectTextMap.noIterationItem],
[{ group: { iterations: {} } }, iterationSelectTextMap.noIterationItem], it('should hide the dropdown', async () => {
[ expect(findDropdown().isVisible()).toBe(true);
{ group: { iterations: { nodes: ['nodes'] } } },
[...iterationSelectTextMap.noIterationItem, 'nodes'], findDropdown().vm.$emit('hidden');
], await wrapper.vm.$nextTick();
])('when %j as an argument it returns %j', (data, value) => {
const { update } = wrapper.vm.$options.apollo.iterations; expect(findDropdown().isVisible()).toBe(false);
expect(update(data)).toEqual(value);
});
}); });
});
});
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', () => { await toggleDropdown();
createComponent({}); 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', () => { describe('currentIterations', () => {
createComponent({}); it('should call createFlash and Sentry if currentIterations query fails', async () => {
createComponentWithApollo({
currentIterationSpy: jest.fn().mockRejectedValue(error),
});
const { update } = wrapper.vm.$options.apollo.iterations; await waitForPromises();
// needed to access this.$options in update
const boundUpdate = update.bind(wrapper.vm);
expect(boundUpdate({ group: { iterations: { nodes: [] } } })).toEqual( expect(createFlash).toHaveBeenNthCalledWith(1, {
iterationSelectTextMap.noIterationItem, message: wrapper.vm.$options.i18n.currentIterationFetchError,
); });
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
}); });
}); });
describe('currentIteration', () => { describe('iterations', () => {
describe('when passes an object that doesnt contain the correct values', () => { let groupIterationsSpy;
beforeEach(() => {
createComponent({}); it('should call createFlash and Sentry if iterations query fails', async () => {
createComponentWithApollo({
groupIterationsSpy: jest.fn().mockRejectedValue(error),
}); });
it.each([ await toggleDropdown();
[{}, undefined], jest.runOnlyPendingTimers();
[{ project: { issue: {} } }, undefined], await waitForPromises();
[{ project: { issue: { iteration: {} } } }, {}],
])('when %j as an argument it returns %j', (data, value) => {
const { update } = wrapper.vm.$options.apollo.currentIteration;
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', () => { describe('when a user is searching', () => {
it('returns the id', () => { const mockSearchTerm = 'foobar';
createComponent({});
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({ expect(groupIterationsSpy).toHaveBeenNthCalledWith(1, {
id: '123', fullPath: mockIssue.groupPath,
title: `"${mockSearchTerm}"`,
state: iterationDisplayState,
}); });
}); });
}); });
...@@ -377,3 +540,4 @@ describe('IterationSelect', () => { ...@@ -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',
},
},
};
...@@ -12089,6 +12089,12 @@ msgstr "" ...@@ -12089,6 +12089,12 @@ msgstr ""
msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later." msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later."
msgstr "" 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." msgid "Failed to find import label for Jira import."
msgstr "" msgstr ""
...@@ -19851,6 +19857,9 @@ msgstr "" ...@@ -19851,6 +19857,9 @@ msgstr ""
msgid "No iteration" msgid "No iteration"
msgstr "" msgstr ""
msgid "No iterations found"
msgstr ""
msgid "No iterations to show" msgid "No iterations to show"
msgstr "" 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