Commit f62b6a67 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '349612-update-iteration-dropdowns' into 'master'

Update iteration dropdown to use dates

See merge request gitlab-org/gitlab!78361
parents db24476b 92287e52
......@@ -184,29 +184,15 @@ export default {
:issuable-type="issuableType"
data-testid="sidebar-milestones"
/>
<template v-if="!glFeatures.iterationCadences">
<sidebar-dropdown-widget
v-if="iterationFeatureAvailable && !isIncidentSidebar"
:iid="activeBoardItem.iid"
issuable-attribute="iteration"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
class="gl-mt-5"
data-testid="iteration-edit"
/>
</template>
<template v-else>
<iteration-sidebar-dropdown-widget
v-if="iterationFeatureAvailable && !isIncidentSidebar"
:iid="activeBoardItem.iid"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
class="gl-mt-5"
data-testid="iteration-edit"
/>
</template>
<iteration-sidebar-dropdown-widget
v-if="iterationFeatureAvailable && !isIncidentSidebar"
:iid="activeBoardItem.iid"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
class="gl-mt-5"
data-testid="iteration-edit"
/>
</div>
<board-sidebar-time-tracker />
<sidebar-date-widget
......
......@@ -17,8 +17,8 @@ import { ListType } from '~/boards/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { groupByIterationCadences } from 'ee/iterations/utils';
import IterationPeriod from 'ee/iterations/components/iteration_period.vue';
import { groupByIterationCadences, getIterationPeriod } from 'ee/iterations/utils';
import IterationTitle from 'ee/iterations/components/iteration_title.vue';
export const listTypeInfo = {
[ListType.label]: {
......@@ -66,7 +66,7 @@ export default {
GlFormGroup,
GlFormRadio,
GlFormRadioGroup,
IterationPeriod,
IterationTitle,
},
directives: {
GlTooltip,
......@@ -240,6 +240,8 @@ export default {
this.selectedItem = { ...item };
}
},
getIterationPeriod,
},
};
</script>
......@@ -320,13 +322,13 @@ export default {
</div>
</gl-dropdown-section-header>
<gl-dropdown-text v-for="iteration in cadence.iterations" :key="iteration.id">
<gl-form-radio :value="iteration.id" :aria-describedby="cadence.id">
{{ iteration.title }}
<div class="gl-display-inline-block">
<IterationPeriod data-testid="new-column-iteration-period">{{
iteration.period
}}</IterationPeriod>
</div>
<gl-form-radio
:value="iteration.id"
:aria-describedby="cadence.id"
data-testid="new-column-iteration-item"
>
{{ iteration.period }}
<iteration-title v-if="iteration.title" :title="iteration.title" />
</gl-form-radio>
</gl-dropdown-text>
</div>
......@@ -363,6 +365,14 @@ export default {
:sub-label="`@${item.username}`"
:src="item.avatarUrl"
/>
<div
v-else-if="iterationTypeSelected"
class="gl-display-inline-block"
data-testid="new-column-iteration-item"
>
{{ getIterationPeriod(item) }}
<iteration-title v-if="item.title" :title="item.title" />
</div>
<div v-else class="gl-display-inline-block">
{{ item.title }}
</div>
......
<script>
export default {
name: 'IterationTitle',
props: {
title: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="gl-text-gray-400">
{{ title }}
</div>
</template>
......@@ -8,15 +8,15 @@ import {
GlTooltipDirective,
GlLoadingIcon,
} from '@gitlab/ui';
import IterationPeriod from 'ee/iterations/components/iteration_period.vue';
import { groupByIterationCadences } from 'ee/iterations/utils';
import IterationTitle from 'ee/iterations/components/iteration_title.vue';
import { groupByIterationCadences, getIterationPeriod } from 'ee/iterations/utils';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { iterationSelectTextMap, iterationDisplayState } from '../constants';
import groupIterationsQuery from '../queries/iterations.query.graphql';
export default {
noIteration: { title: iterationSelectTextMap.noIteration, id: null },
noIteration: { text: iterationSelectTextMap.noIteration, id: null },
directives: {
GlTooltip: GlTooltipDirective,
},
......@@ -27,7 +27,7 @@ export default {
GlSearchBoxByType,
GlDropdownSectionHeader,
GlLoadingIcon,
IterationPeriod,
IterationTitle,
},
mixins: [glFeatureFlagMixin()],
apollo: {
......@@ -72,8 +72,10 @@ export default {
iterationCadences() {
return groupByIterationCadences(this.iterations);
},
title() {
return this.currentIteration?.title || __('Select iteration');
dropdownSelectedText() {
return this.currentIteration?.startDate || this.currentIteration?.period
? this.getIterationPeriod(this.currentIteration)
: __('Select iteration');
},
},
methods: {
......@@ -92,12 +94,13 @@ export default {
onDropdownShow() {
this.shouldFetch = true;
},
getIterationPeriod,
},
};
</script>
<template>
<gl-dropdown :text="title" class="gl-w-full" block @show="onDropdownShow">
<gl-dropdown :text="dropdownSelectedText" class="gl-w-full" block @show="onDropdownShow">
<gl-dropdown-section-header class="gl-display-flex! gl-justify-content-center">{{
__('Assign Iteration')
}}</gl-dropdown-section-header>
......@@ -107,7 +110,7 @@ export default {
:is-checked="isIterationChecked($options.noIteration.id)"
@click="onClick($options.noIteration)"
>
{{ $options.noIteration.title }}
{{ $options.noIteration.text }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-loading-icon v-if="$apollo.queries.iterations.loading" size="sm" />
......@@ -118,8 +121,10 @@ export default {
:is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)"
@click="onClick(iterationItem)"
>{{ iterationItem.title }}</gl-dropdown-item
>
{{ getIterationPeriod(iterationItem) }}
<iteration-title v-if="iterationItem.title" :title="iterationItem.title" />
</gl-dropdown-item>
</template>
<template v-else>
<template v-for="(cadence, index) in iterationCadences">
......@@ -134,8 +139,8 @@ export default {
:is-checked="isIterationChecked(iterationItem.id)"
@click="onClick(iterationItem)"
>
{{ iterationItem.title }}
<IterationPeriod>{{ iterationItem.period }}</IterationPeriod>
{{ iterationItem.period }}
<iteration-title v-if="iterationItem.title" :title="iterationItem.title" />
</gl-dropdown-item>
</template>
</template>
......
......@@ -7,9 +7,10 @@ import {
GlLink,
} from '@gitlab/ui';
import SidebarDropdownWidget from 'ee/sidebar/components/sidebar_dropdown_widget.vue';
import IterationPeriod from 'ee/iterations/components/iteration_period.vue';
import IterationTitle from 'ee/iterations/components/iteration_title.vue';
import { getIterationPeriod, groupByIterationCadences } from 'ee/iterations/utils';
import { IssuableType } from '~/issues/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableAttributeType } from '../constants';
export default {
......@@ -21,8 +22,9 @@ export default {
GlIcon,
GlLink,
SidebarDropdownWidget,
IterationPeriod,
IterationTitle,
},
mixins: [glFeatureFlagMixin()],
props: {
attrWorkspacePath: {
required: true,
......@@ -48,12 +50,8 @@ export default {
getCadenceTitle(currentIteration) {
return currentIteration?.iterationCadence?.title;
},
getIterationPeriod(iteration) {
return getIterationPeriod({ startDate: iteration?.startDate, dueDate: iteration?.dueDate });
},
groupByIterationCadences(iterations) {
return groupByIterationCadences(iterations);
},
groupByIterationCadences,
getIterationPeriod,
},
};
</script>
......@@ -66,8 +64,8 @@ export default {
:issuable-type="issuableType"
:workspace-path="workspacePath"
>
<template #value="{ attributeTitle, attributeUrl, currentAttribute }">
<p class="gl-font-weight-bold gl-line-height-20 gl-m-0">
<template #value="{ attributeUrl, currentAttribute }">
<p v-if="glFeatures.iterationCadences" class="gl-font-weight-bold gl-line-height-20 gl-m-0">
{{ getCadenceTitle(currentAttribute) }}
</p>
<gl-link
......@@ -77,15 +75,19 @@ export default {
>
<div>
<gl-icon name="iteration" class="gl-mr-1" />
{{ attributeTitle }}
{{ getIterationPeriod(currentAttribute) }}
</div>
<IterationPeriod>{{ getIterationPeriod(currentAttribute) }}</IterationPeriod>
<iteration-title v-if="currentAttribute.title" :title="currentAttribute.title" />
</gl-link>
</template>
<template #list="{ attributesList = [], isAttributeChecked, updateAttribute }">
<template v-for="(cadence, index) in groupByIterationCadences(attributesList)">
<gl-dropdown-divider v-if="index !== 0" :key="index" />
<gl-dropdown-section-header :key="cadence.title">
<gl-dropdown-divider v-if="index !== 0 && glFeatures.iterationCadences" :key="index" />
<gl-dropdown-section-header
v-if="glFeatures.iterationCadences"
:key="cadence.title"
data-testid="cadence-title"
>
{{ cadence.title }}
</gl-dropdown-section-header>
<gl-dropdown-item
......@@ -96,8 +98,8 @@ export default {
:data-testid="`${$options.issuableAttribute}-items`"
@click="updateAttribute(iteration.id)"
>
{{ iteration.title }}
<IterationPeriod>{{ iteration.period }}</IterationPeriod>
{{ iteration.period }}
<iteration-title v-if="iteration.title" :title="iteration.title" />
</gl-dropdown-item>
</template>
</template>
......
......@@ -128,10 +128,6 @@ function mountIterationSelect() {
const { groupPath, canEdit, projectPath, issueIid } = el.dataset;
const IterationDropdown = gon.features.iterationCadences
? IterationSidebarDropdownWidget
: SidebarDropdownWidget;
return new Vue({
el,
apolloProvider,
......@@ -140,7 +136,7 @@ function mountIterationSelect() {
isClassicSidebar: true,
},
render: (createElement) =>
createElement(IterationDropdown, {
createElement(IterationSidebarDropdownWidget, {
props: {
attrWorkspacePath: groupPath,
workspacePath: projectPath,
......
......@@ -2,7 +2,9 @@
require 'spec_helper'
RSpec.describe 'User adds milestone lists', :js do
RSpec.describe 'User adds milestone/iterations lists', :js do
include IterationHelpers
let_it_be(:group) { create(:group, :nested) }
let_it_be(:project) { create(:project, :public, namespace: group) }
let_it_be(:group_board) { create(:board, group: group) }
......@@ -62,9 +64,10 @@ RSpec.describe 'User adds milestone lists', :js do
end
it 'creates iteration column' do
add_list('Iteration', iteration.title)
period = iteration_period(iteration)
add_list('Iteration', period)
expect(page).to have_selector('.board', text: iteration.title)
expect(page).to have_selector('.board', text: period)
expect(find('.board:nth-child(2) .board-card')).to have_content(issue_with_iteration.title)
end
end
......
......@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Issue Sidebar' do
include MobileHelpers
include IterationHelpers
let_it_be(:group) { create(:group, :nested) }
let_it_be(:project) { create(:project, :public, namespace: group) }
......@@ -169,6 +170,7 @@ RSpec.describe 'Issue Sidebar' do
within '[data-testid="iteration-edit"]' do
expect(page).not_to have_text(iteration_cadence.title)
expect(page).to have_text(iteration.title)
expect(page).to have_text(iteration_period(iteration))
end
select_iteration(iteration.title)
......@@ -176,6 +178,7 @@ RSpec.describe 'Issue Sidebar' do
within '[data-testid="select-iteration"]' do
expect(page).not_to have_text(iteration_cadence.title)
expect(page).to have_text(iteration.title)
expect(page).to have_text(iteration_period(iteration))
end
find_and_click_edit_iteration
......@@ -297,8 +300,4 @@ RSpec.describe 'Issue Sidebar' do
wait_for_requests
end
end
def iteration_period(iteration)
"#{iteration.start_date.to_s(:medium)} - #{iteration.due_date.to_s(:medium)}"
end
end
......@@ -573,14 +573,13 @@ exports[`ee/BoardContentSidebar issue sidebar matches the snapshot 1`] = `
workspacepath="gitlab-org/gitlab-test"
/>
<sidebardropdownwidget-stub
attrworkspacepath="gitlab-org"
<iterationsidebardropdownwidget-stub
attr-workspace-path="gitlab-org"
class="gl-mt-5"
data-testid="iteration-edit"
iid="27"
issuableattribute="iteration"
issuabletype="issue"
workspacepath="gitlab-org/gitlab-test"
issuable-type="issue"
workspace-path="gitlab-org/gitlab-test"
/>
</div>
......
......@@ -5,8 +5,10 @@ import Vuex from 'vuex';
import BoardAddNewColumn, { listTypeInfo } from 'ee/boards/components/board_add_new_column.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import IterationTitle from 'ee/iterations/components/iteration_title.vue';
import { ListType } from '~/boards/constants';
import defaultState from '~/boards/stores/state';
import { getIterationPeriod } from 'ee/iterations/utils';
import { mockAssignees, mockLists, mockIterations } from '../mock_data';
const mockLabelList = mockLists[1];
......@@ -46,6 +48,7 @@ describe('BoardAddNewColumn', () => {
BoardAddNewColumnForm,
GlFormRadio,
GlFormRadioGroup,
IterationTitle,
},
data() {
return {
......@@ -94,8 +97,7 @@ describe('BoardAddNewColumn', () => {
const findForm = () => wrapper.findComponent(BoardAddNewColumnForm);
const cancelButton = () => wrapper.findByTestId('cancelAddNewColumn');
const submitButton = () => wrapper.findByTestId('addNewColumnButton');
const findLabels = () => wrapper.findComponent(GlDropdown).findAll('label');
const findIterationPeriod = (item) => item.find('[data-testid="new-column-iteration-period"]');
const findIterationItemAt = (i) => wrapper.findAllByTestId('new-column-iteration-item').at(i);
const listTypeSelect = (type) => {
const radio = wrapper
.findAllComponents(GlFormRadio)
......@@ -110,6 +112,16 @@ describe('BoardAddNewColumn', () => {
await nextTick();
};
const expectIterationWithTitle = () => {
expect(findIterationItemAt(1).text()).toContain(getIterationPeriod(mockIterations[1]));
expect(findIterationItemAt(1).text()).toContain(mockIterations[1].title);
};
const expectIterationWithoutTitle = () => {
expect(findIterationItemAt(0).text()).toContain(getIterationPeriod(mockIterations[0]));
expect(findIterationItemAt(0).findComponent(IterationTitle).exists()).toBe(false);
};
it('clicking cancel hides the form', () => {
const setAddColumnFormVisibility = jest.fn();
mountComponent({
......@@ -238,16 +250,8 @@ describe('BoardAddNewColumn', () => {
const itemList = wrapper.findComponent(GlDropdown).findAllComponents(GlFormRadio);
expect(itemList).toHaveLength(mockIterations.length);
expect(itemList.at(0).attributes('value')).toBe(mockIterations[0].id);
expect(itemList.at(1).attributes('value')).toBe(mockIterations[1].id);
});
describe('iteration_cadences feature flag is off', () => {
it('does not display iteration period', async () => {
const labels = findLabels();
expect(findIterationPeriod(labels.at(0)).exists()).toBe(false);
expect(findIterationPeriod(labels.at(1)).exists()).toBe(false);
});
expectIterationWithoutTitle();
expectIterationWithTitle();
});
});
......@@ -280,12 +284,9 @@ describe('BoardAddNewColumn', () => {
expect(cadenceTitles).toEqual(cadenceTitles.map((_, idx) => getCadenceTitleFromMocks(idx)));
});
it('displays iteration period', async () => {
const iterations = wrapper.findAllByTestId('new-column-iteration-period');
expect(iterations.at(0).text()).toContain('Oct 5, 2021 - Oct 10, 2021');
expect(findIterationPeriod(iterations.at(0)).isVisible()).toBe(true);
expect(iterations.at(1).text()).toContain('Oct 12, 2021 - Oct 17, 2021');
expect(findIterationPeriod(iterations.at(1)).isVisible()).toBe(true);
it('displays iteration period optionally with title', async () => {
expectIterationWithoutTitle();
expectIterationWithTitle();
});
});
});
......@@ -80,6 +80,7 @@ describe('ee/BoardContentSidebar', () => {
SidebarSubscriptionsWidget: true,
SidebarWeightWidget: true,
SidebarDropdownWidget: true,
IterationSidebarDropdownWidget: true,
SidebarTodoWidget: true,
MountingPortal: true,
},
......
......@@ -121,7 +121,7 @@ export const mockMilestones = [
export const mockIterations = [
{
id: 'gid://gitlab/Iteration/1',
title: 'Iteration 1',
title: null,
iterationCadence: {
id: 'gid://gitlab/Iterations::Cadence/1',
title: 'GitLab.org Iterations',
......@@ -131,7 +131,7 @@ export const mockIterations = [
},
{
id: 'gid://gitlab/Iteration/2',
title: 'Iteration 2',
title: 'Some iteration',
iterationCadence: {
id: 'gid://gitlab/Iterations::Cadence/2',
title: 'GitLab.org Iterations: Volume II',
......
import { shallowMount } from '@vue/test-utils';
import IterationTitle from 'ee/iterations/components/iteration_title.vue';
describe('Iterations title', () => {
let wrapper;
const createComponent = (propsData) => {
wrapper = shallowMount(IterationTitle, {
propsData,
});
};
afterEach(() => {
wrapper.destroy();
});
it('shows empty state', () => {
createComponent({ title: 'abc' });
expect(wrapper.html()).toHaveText('abc');
});
});
......@@ -12,6 +12,8 @@ import VueApollo from 'vue-apollo';
import IterationDropdown from 'ee/sidebar/components/iteration_dropdown.vue';
import groupIterationsQuery from 'ee/sidebar/queries/iterations.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { getIterationPeriod } from 'ee/iterations/utils';
import IterationTitle from 'ee/iterations/components/iteration_title.vue';
Vue.use(VueApollo);
......@@ -32,7 +34,7 @@ const TEST_ITERATIONS = [
},
{
id: '22',
title: 'Another Test Title',
title: null,
startDate: '2021-10-06',
dueDate: '2021-10-10',
webUrl: '',
......@@ -44,7 +46,7 @@ const TEST_ITERATIONS = [
},
{
id: '33',
title: 'Yet Another Test Title',
title: null,
startDate: '2021-10-11',
dueDate: '2021-10-15',
webUrl: '',
......@@ -83,15 +85,11 @@ describe('IterationDropdown', () => {
await nextTick();
jest.runOnlyPendingTimers();
};
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findDropdownItemsAt = (i) => findDropdownItems().at(i);
const findDropdownItemWithText = (text) =>
findDropdownItems().wrappers.find((x) => x.text() === text);
const findDropdownItemsData = () =>
findDropdownItems().wrappers.map((x) => ({
isCheckItem: x.props('isCheckItem'),
isChecked: x.props('isChecked'),
text: x.text(),
}));
findDropdownItems().wrappers.find((x) => x.text().includes(text));
const selectDropdownItemAndWait = async (text) => {
const item = findDropdownItemWithText(text);
......@@ -115,6 +113,9 @@ describe('IterationDropdown', () => {
propsData: {
fullPath: TEST_FULL_PATH,
},
stubs: {
IterationTitle,
},
provide: {
glFeatures: {
iterationCadences,
......@@ -173,16 +174,21 @@ describe('IterationDropdown', () => {
expect(isLoading()).toBe(false);
});
it('shows dropdown items', () => {
const result = [IterationDropdown.noIteration].concat(TEST_ITERATIONS);
it('shows checkable dropdown items in unchecked state', () => {
expect(findDropdownItems().wrappers.every((x) => x.props('isCheckItem'))).toBe(true);
expect(findDropdownItems().wrappers.every((x) => x.props('isChecked'))).toBe(false);
});
expect(findDropdownItemsData()).toEqual(
result.map((x) => ({
isCheckItem: true,
isChecked: false,
text: x.title,
})),
);
it('populates dropdown items with correct names', () => {
// "No iteration" dropdown item
expect(findDropdownItemsAt(0).text()).toContain(IterationDropdown.noIteration.text);
// Iteration with title
expect(findDropdownItemsAt(1).text()).toContain(getIterationPeriod(TEST_ITERATIONS[0]));
expect(findDropdownItemsAt(1).text()).toContain(TEST_ITERATIONS[0].title);
// Iteration without title
expect(findDropdownItemsAt(2).text()).toContain(getIterationPeriod(TEST_ITERATIONS[1]));
});
it('does not re-query if opened again', async () => {
......@@ -192,41 +198,40 @@ describe('IterationDropdown', () => {
expect(groupIterationsSpy).not.toHaveBeenCalled();
});
describe.each([0, 1, 2])('when item %s is selected', (index) => {
const allIterations = [IterationDropdown.noIteration].concat(TEST_ITERATIONS);
const selected = allIterations[index];
const asNotChecked = ({ title }) => ({ isCheckItem: true, isChecked: false, text: title });
describe.each([
{
text: IterationDropdown.noIteration.text,
dropdownText: 'Select iteration',
iteration: IterationDropdown.noIteration,
},
{
text: getIterationPeriod(TEST_ITERATIONS[0]),
dropdownText: getIterationPeriod(TEST_ITERATIONS[0]),
iteration: TEST_ITERATIONS[0],
},
{
text: getIterationPeriod(TEST_ITERATIONS[1]),
dropdownText: getIterationPeriod(TEST_ITERATIONS[1]),
iteration: TEST_ITERATIONS[1],
},
])("when iteration '%s' is selected", ({ text, dropdownText, iteration }) => {
beforeEach(async () => {
await selectDropdownItemAndWait(selected.title);
});
it('shows item as checked', () => {
const prevSelected = allIterations.slice(0, index);
const afterSelected = allIterations.slice(index + 1);
expect(findDropdownItemsData()).toEqual([
...prevSelected.map(asNotChecked),
{
isCheckItem: true,
isChecked: true,
text: selected.title,
},
...afterSelected.map(asNotChecked),
]);
await selectDropdownItemAndWait(text);
});
it('emits event', () => {
expect(wrapper.emitted('onIterationSelect')).toEqual([[selected]]);
it('shows item as checked with text and emits event', () => {
expect(findDropdown().props('text')).toBe(dropdownText);
expect(findDropdownItemWithText(text).props('isChecked')).toBe(true);
expect(wrapper.emitted('onIterationSelect')).toEqual([[iteration]]);
});
describe('when item is clicked again', () => {
beforeEach(async () => {
await selectDropdownItemAndWait(selected.title);
await selectDropdownItemAndWait(text);
});
it('shows item as unchecked', () => {
expect(findDropdownItemsData()).toEqual(allIterations.map(asNotChecked));
expect(findDropdownItems().wrappers.every((x) => x.props('isChecked'))).toBe(false);
});
it('emits event', () => {
......@@ -271,18 +276,18 @@ describe('IterationDropdown', () => {
expect(dropdownItems.at(0).text()).toBe('Assign Iteration');
expect(dropdownItems.at(1).text()).toContain('No iteration');
expect(dropdownItems.at(2).findComponent(GlDropdownDivider).exists()).toBe(true);
expect(dropdownItems.at(3).findComponent(GlDropdownSectionHeader).text()).toBe('My Cadence');
expect(dropdownItems.at(4).text()).toContain(getIterationPeriod(TEST_ITERATIONS[0]));
expect(dropdownItems.at(4).text()).toContain('Test Title');
expect(dropdownItems.at(4).text()).toContain('Oct 1, 2021 - Oct 5, 2021');
expect(dropdownItems.at(5).text()).toContain('Yet Another Test Title');
expect(dropdownItems.at(5).text()).toContain('Oct 11, 2021 - Oct 15, 2021');
expect(dropdownItems.at(5).text()).toContain(getIterationPeriod(TEST_ITERATIONS[2]));
expect(dropdownItems.at(6).findComponent(GlDropdownDivider).exists()).toBe(true);
expect(dropdownItems.at(7).findComponent(GlDropdownSectionHeader).text()).toBe(
'My Second Cadence',
);
expect(dropdownItems.at(8).text()).toContain('Another Test Title');
expect(dropdownItems.at(8).text()).toContain('Oct 6, 2021 - Oct 10, 2021');
expect(dropdownItems.at(8).text()).toContain(getIterationPeriod(TEST_ITERATIONS[1]));
});
});
});
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import IterationSidebarDropdownWidget from 'ee/sidebar/components/iteration_sidebar_dropdown_widget.vue';
import SidebarDropdownWidget from 'ee/sidebar/components/sidebar_dropdown_widget.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { IssuableType } from '~/issues/constants';
import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql';
import projectIssueIterationQuery from 'ee/sidebar/queries/project_issue_iteration.query.graphql';
import { IssuableAttributeType, issuableAttributesQueries } from 'ee/sidebar/constants';
import { getIterationPeriod } from 'ee/iterations/utils';
import {
mockIssue,
mockGroupIterationsResponse,
mockIteration1,
mockIteration2,
mockCurrentIterationResponse1,
mockCurrentIterationResponse2,
} from '../mock_data';
import { clickEdit } from '../helpers';
Vue.use(VueApollo);
describe('IterationSidebarDropdownWidget', () => {
let wrapper;
let mockApollo;
const defaultProps = {
attrWorkspacePath: 'attr/workspace/path',
iid: 'iid',
issuableType: IssuableType.Issue,
workspacePath: 'workspace/path',
};
const findCurrentIterationText = () => wrapper.findByTestId('select-iteration').text();
const findIterationItemsTextAt = (at) => wrapper.findAllByTestId('iteration-items').at(at).text();
const findIterationCadenceTitleAt = (at) =>
wrapper.findAllByTestId('cadence-title').at(at).text();
const createComponent = () =>
shallowMount(IterationSidebarDropdownWidget, {
propsData: defaultProps,
});
const createComponentWithApollo = async ({
iterationCadences = false,
currentIterationResponse = mockCurrentIterationResponse1,
} = {}) => {
mockApollo = createMockApollo([
[groupIterationsQuery, jest.fn().mockResolvedValue(mockGroupIterationsResponse)],
[projectIssueIterationQuery, jest.fn().mockResolvedValue(currentIterationResponse)],
]);
wrapper = extendedWrapper(
mount(IterationSidebarDropdownWidget, {
provide: {
glFeatures: { iterationCadences },
issuableAttributesQueries,
canUpdate: true,
},
apolloProvider: mockApollo,
propsData: {
workspacePath: mockIssue.projectPath,
attrWorkspacePath: mockIssue.groupPath,
iid: mockIssue.iid,
issuableType: IssuableType.Issue,
issuableAttribute: IssuableAttributeType.Iteration,
},
}),
);
jest.runOnlyPendingTimers();
await waitForPromises();
};
afterEach(() => {
wrapper.destroy();
});
describe('SidebarDropdownWidget component', () => {
it('renders', () => {
wrapper = createComponent();
describe('iteration_cadences feature flag is off', () => {
describe('when showing the current iteration (dropdown is closed)', () => {
it('renders just iteration period for iteration without title', async () => {
await createComponentWithApollo();
expect(findCurrentIterationText()).toContain(getIterationPeriod(mockIteration1));
});
it('renders iteration period with optional title for iteration with title', async () => {
await createComponentWithApollo({
iterationCadences: false,
currentIterationResponse: mockCurrentIterationResponse2,
});
expect(findCurrentIterationText()).toContain(getIterationPeriod(mockIteration2));
expect(findCurrentIterationText()).toContain(mockIteration2.title);
});
});
describe('when listing iterations in the dropdown', () => {
it('renders iterations', async () => {
await createComponentWithApollo();
await clickEdit(wrapper);
// mockIteration1 has no title
expect(findIterationItemsTextAt(0)).toContain(getIterationPeriod(mockIteration1));
// mockIteration2 has a title
expect(findIterationItemsTextAt(1)).toContain(getIterationPeriod(mockIteration2));
expect(findIterationItemsTextAt(1)).toContain(mockIteration2.title);
});
});
});
describe('iteration_cadences feature flag is on', () => {
describe('when showing the current iteration (dropdown is closed)', () => {
it('renders cadence title', async () => {
await createComponentWithApollo({ iterationCadences: true });
expect(findCurrentIterationText()).toContain(mockIteration1.iterationCadence.title);
});
it('renders just iteration period for iteration without title', async () => {
await createComponentWithApollo({ iterationCadences: true });
expect(findCurrentIterationText()).toContain(getIterationPeriod(mockIteration1));
});
it('renders iteration period with optional title for iteration with title', async () => {
await createComponentWithApollo({
iterationCadences: true,
currentIterationResponse: mockCurrentIterationResponse2,
});
expect(findCurrentIterationText()).toContain(getIterationPeriod(mockIteration2));
expect(findCurrentIterationText()).toContain(mockIteration2.title);
});
});
describe('when listing iterations in the dropdown', () => {
it('renders iterations with cadence names', async () => {
await createComponentWithApollo({ iterationCadences: true });
await clickEdit(wrapper);
// mockIteration1 has no title
expect(findIterationCadenceTitleAt(0)).toContain(mockIteration1.iterationCadence.title);
expect(findIterationItemsTextAt(0)).toContain(getIterationPeriod(mockIteration1));
expect(wrapper.findComponent(SidebarDropdownWidget).props()).toEqual({
...defaultProps,
issuableAttribute: 'iteration',
// mockIteration2 has a title
expect(findIterationCadenceTitleAt(1)).toContain(mockIteration2.iterationCadence.title);
expect(findIterationItemsTextAt(1)).toContain(getIterationPeriod(mockIteration2));
expect(findIterationItemsTextAt(1)).toContain(mockIteration2.title);
});
});
});
......
......@@ -7,7 +7,7 @@ import {
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import SidebarDropdownWidget from 'ee/sidebar/components/sidebar_dropdown_widget.vue';
import { IssuableAttributeType, issuableAttributesQueries } from 'ee/sidebar/constants';
......@@ -20,6 +20,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { IssuableType } from '~/issues/constants';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { clickEdit, search } from '../helpers';
import {
mockIssue,
......@@ -42,44 +43,16 @@ describe('SidebarDropdownWidget', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownText = () => wrapper.findComponent(GlDropdownText);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findDropdownItemWithText = (text) =>
findAllDropdownItems().wrappers.find((x) => x.text() === text);
const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findEditButton = () => findSidebarEditableItem().find('[data-testid="edit-button"]');
const findSelectedAttribute = () => wrapper.findByTestId('select-epic');
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 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 attributes list to be fetched.
await waitForApollo();
};
// Used with createComponent which shallow mounts components
const toggleDropdown = async () => {
wrapper.findComponent(SidebarEditableItem).vm.$emit('open');
await waitForDropdown();
await waitForPromises();
};
const createComponentWithApollo = async ({
......@@ -109,7 +82,8 @@ describe('SidebarDropdownWidget', () => {
}),
);
await waitForApollo();
jest.runOnlyPendingTimers();
await waitForPromises();
};
const createComponent = ({
......@@ -165,9 +139,7 @@ describe('SidebarDropdownWidget', () => {
await toggleDropdown();
findSearchBox().vm.$emit('input', 'non existing epics');
await nextTick();
await search(wrapper, 'non existing epics');
expect(findDropdownText().text()).toBe('No open iteration found');
});
......@@ -192,7 +164,7 @@ describe('SidebarDropdownWidget', () => {
requestHandlers: [[projectIssueEpicMutation, epicMutationSpy]],
});
await clickEdit();
await clickEdit(wrapper);
});
it('renders the dropdown on clicking edit', async () => {
......@@ -231,7 +203,7 @@ describe('SidebarDropdownWidget', () => {
groupEpicsSpy: jest.fn().mockRejectedValue(error),
});
await clickEdit();
await clickEdit(wrapper);
expect(createFlash).toHaveBeenCalledWith({
message: 'Failed to fetch the epic for this issue. Please try again.',
......@@ -246,7 +218,7 @@ describe('SidebarDropdownWidget', () => {
expect(groupEpicsSpy).not.toHaveBeenCalled();
await clickEdit();
await clickEdit(wrapper);
expect(groupEpicsSpy).toHaveBeenNthCalledWith(1, {
fullPath: mockIssue.groupPath,
......@@ -262,15 +234,11 @@ describe('SidebarDropdownWidget', () => {
groupEpicsSpy = jest.fn().mockResolvedValueOnce(emptyGroupEpicsResponse);
await createComponentWithApollo({ groupEpicsSpy });
await clickEdit();
await clickEdit(wrapper);
});
it('sends a groupEpics query with the entered search term "foo" and in TITLE param', async () => {
findSearchBox().vm.$emit('input', mockSearchTerm);
await nextTick();
// Account for debouncing
jest.runAllTimers();
await search(wrapper, mockSearchTerm);
expect(groupEpicsSpy).toHaveBeenNthCalledWith(2, {
fullPath: mockIssue.groupPath,
......@@ -287,11 +255,11 @@ describe('SidebarDropdownWidget', () => {
groupEpicsSpy = jest.fn().mockResolvedValueOnce(emptyGroupEpicsResponse);
await createComponentWithApollo({ groupEpicsSpy });
await clickEdit();
await clickEdit(wrapper);
});
it('sends a groupEpics query with empty title and undefined in param', async () => {
await nextTick();
await waitForPromises();
// Account for debouncing
jest.runAllTimers();
......@@ -304,11 +272,7 @@ describe('SidebarDropdownWidget', () => {
});
it('sends a groupEpics query for an IID with the entered search term "&1"', async () => {
findSearchBox().vm.$emit('input', '&1');
await nextTick();
// Account for debouncing
jest.runAllTimers();
await search(wrapper, '&1');
expect(groupEpicsSpy).toHaveBeenNthCalledWith(2, {
fullPath: mockIssue.groupPath,
......
import { GlSearchBoxByType } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
const findSidebarEditableItem = (wrapper) => wrapper.findComponent(SidebarEditableItem);
const findEditButton = (wrapper) =>
findSidebarEditableItem(wrapper).find('[data-testid="edit-button"]');
const findSearchBox = (wrapper) => wrapper.findComponent(GlSearchBoxByType);
export const search = async (wrapper, searchTerm) => {
findSearchBox(wrapper).vm.$emit('input', searchTerm);
await waitForPromises();
jest.runAllTimers(); // Account for debouncing
};
// Used with createComponentWithApollo which uses 'mount'
export const clickEdit = async (wrapper) => {
await findEditButton(wrapper).trigger('click');
// We should wait for attributes list to be fetched.
jest.runAllTimers();
await waitForPromises();
};
......@@ -9,12 +9,25 @@ export const mockIssue = {
export const mockIssueId = 'gid://gitlab/Issue/1';
export const mockCadence1 = {
id: 'gid://gitlab/Iterations::Cadence/1',
title: 'Plan cadence',
};
export const mockCadence2 = {
id: 'gid://gitlab/Iterations::Cadence/2',
title: 'Automatic cadence',
};
export const mockIteration1 = {
__typename: 'Iteration',
id: 'gid://gitlab/Iteration/1',
title: 'Foobar Iteration',
title: null,
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/iterations/1',
state: 'opened',
startDate: '2021-10-05',
dueDate: '2021-10-10',
iterationCadence: mockCadence1,
};
export const mockIteration2 = {
......@@ -23,6 +36,9 @@ export const mockIteration2 = {
title: 'Awesome Iteration',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/iterations/2',
state: 'opened',
startDate: '2021-10-12',
dueDate: '2021-10-17',
iterationCadence: mockCadence2,
};
export const mockEpic1 = {
......@@ -45,7 +61,7 @@ export const mockGroupIterationsResponse = {
data: {
workspace: {
id: '1',
iterations: {
attributes: {
nodes: [mockIteration1, mockIteration2],
},
__typename: 'IterationConnection',
......@@ -93,6 +109,36 @@ export const emptyGroupEpicsResponse = {
},
};
export const mockCurrentIterationResponse1 = {
data: {
errors: [],
workspace: {
id: '1',
issuable: {
id: mockIssueId,
attribute: mockIteration1,
__typename: 'Issue',
},
__typename: 'Project',
},
},
};
export const mockCurrentIterationResponse2 = {
data: {
errors: [],
workspace: {
id: '1',
issuable: {
id: mockIssueId,
attribute: mockIteration2,
__typename: 'Issue',
},
__typename: 'Project',
},
},
};
export const noCurrentIterationResponse = {
data: {
workspace: {
......
# frozen_string_literal: true
module IterationHelpers
def iteration_period(iteration)
"#{iteration.start_date.to_s(:medium)} - #{iteration.due_date.to_s(:medium)}"
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