Commit ed7049fb authored by Florie Guibert's avatar Florie Guibert Committed by Simon Knox

Add iteration selector to board scope

Changelog: changed
EE: true
parent b17b71f0
...@@ -3,6 +3,7 @@ import { GlModal, GlAlert } from '@gitlab/ui'; ...@@ -3,6 +3,7 @@ import { GlModal, GlAlert } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex'; import { mapGetters, mapActions, mapState } from 'vuex';
import { getParameterByName, visitUrl } from '~/lib/utils/url_utility'; import { getParameterByName, visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { formType } from '../constants'; import { formType } from '../constants';
import createBoardMutation from '../graphql/board_create.mutation.graphql'; import createBoardMutation from '../graphql/board_create.mutation.graphql';
...@@ -15,6 +16,7 @@ const boardDefaults = { ...@@ -15,6 +16,7 @@ const boardDefaults = {
name: '', name: '',
labels: [], labels: [],
milestone: {}, milestone: {},
iterationCadence: {},
iteration: {}, iteration: {},
assignee: {}, assignee: {},
weight: null, weight: null,
...@@ -41,6 +43,7 @@ export default { ...@@ -41,6 +43,7 @@ export default {
BoardConfigurationOptions, BoardConfigurationOptions,
GlAlert, GlAlert,
}, },
mixins: [glFeatureFlagMixin()],
inject: { inject: {
fullPath: { fullPath: {
default: '', default: '',
...@@ -231,9 +234,12 @@ export default { ...@@ -231,9 +234,12 @@ export default {
this.board = { ...boardDefaults, ...this.currentBoard }; this.board = { ...boardDefaults, ...this.currentBoard };
} }
}, },
setIteration(iterationId) { setIteration(iteration) {
if (this.glFeatures.iterationCadences) {
this.board.iterationCadenceId = iteration.iterationCadenceId;
}
this.$set(this.board, 'iteration', { this.$set(this.board, 'iteration', {
id: iterationId, id: iteration.id,
}); });
}, },
setBoardLabels(labels) { setBoardLabels(labels) {
......
<script> <script>
import { import {
GlIcon,
GlLoadingIcon, GlLoadingIcon,
GlDropdown, GlDropdown,
GlDropdownForm, GlDropdownForm,
GlDropdownDivider, GlDropdownDivider,
GlDropdownItem, GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType, GlSearchBoxByType,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default { export default {
components: { components: {
GlIcon,
GlLoadingIcon, GlLoadingIcon,
GlDropdown, GlDropdown,
GlDropdownForm, GlDropdownForm,
GlDropdownDivider, GlDropdownDivider,
GlDropdownItem, GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType, GlSearchBoxByType,
TooltipOnTruncate,
}, },
props: { props: {
selectText: { selectText: {
...@@ -39,6 +45,11 @@ export default { ...@@ -39,6 +45,11 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
groupedOptions: {
type: Array,
required: false,
default: () => [],
},
isLoading: { isLoading: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -79,11 +90,7 @@ export default { ...@@ -79,11 +90,7 @@ export default {
if (Array.isArray(this.selected)) { if (Array.isArray(this.selected)) {
return this.selected.some((label) => label.title === option.title); return this.selected.some((label) => label.title === option.title);
} }
return ( return this.selected && option.id && this.selected.id === option.id;
this.selected &&
((option.name && this.selected.name === option.name) ||
(option.title && this.selected.title === option.title))
);
}, },
showDropdown() { showDropdown() {
this.$refs.dropdown.show(); this.$refs.dropdown.show();
...@@ -101,6 +108,9 @@ export default { ...@@ -101,6 +108,9 @@ export default {
// TODO: this has some knowledge of the context where the component is used. We could later rework it. // TODO: this has some knowledge of the context where the component is used. We could later rework it.
return option.username || null; return option.username || null;
}, },
optionKey(option) {
return option.key ? option.key : option.id;
},
}, },
i18n: { i18n: {
noMatchingResults: __('No matching results'), noMatchingResults: __('No matching results'),
...@@ -154,10 +164,10 @@ export default { ...@@ -154,10 +164,10 @@ export default {
</template> </template>
<gl-dropdown-item <gl-dropdown-item
v-for="option in options" v-for="option in options"
:key="option.id" :key="optionKey(option)"
:is-checked="isSelected(option)" :is-checked="isSelected(option)"
:is-check-centered="true" is-check-centered
:is-check-item="true" is-check-item
:avatar-url="avatarUrl(option)" :avatar-url="avatarUrl(option)"
:secondary-text="secondaryText(option)" :secondary-text="secondaryText(option)"
data-testid="unselected-option" data-testid="unselected-option"
...@@ -167,6 +177,36 @@ export default { ...@@ -167,6 +177,36 @@ export default {
{{ option.title }} {{ option.title }}
</slot> </slot>
</gl-dropdown-item> </gl-dropdown-item>
<template v-for="(optionGroup, index) in groupedOptions">
<gl-dropdown-divider v-if="index !== 0" :key="index" />
<gl-dropdown-section-header :key="optionGroup.id">
<div class="gl-display-flex gl-max-w-full">
<tooltip-on-truncate
:title="optionGroup.title"
class="gl-text-truncate gl-flex-grow-1"
>
{{ optionGroup.title }}
</tooltip-on-truncate>
<span v-if="optionGroup.secondaryText" class="gl-float-right gl-font-weight-normal">
<gl-icon name="clock" class="gl-mr-2" />
{{ optionGroup.secondaryText }}
</span>
</div>
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="option in optionGroup.options"
:key="optionKey(option)"
:is-checked="isSelected(option)"
is-check-centered
is-check-item
data-testid="unselected-option"
@click="selectOption(option)"
>
<slot name="item" :item="option">
{{ option.title }}
</slot>
</gl-dropdown-item>
</template>
<gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!"> <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!">
{{ $options.i18n.noMatchingResults }} {{ $options.i18n.noMatchingResults }}
</gl-dropdown-item> </gl-dropdown-item>
......
...@@ -189,13 +189,18 @@ export function transformBoardConfig(boardConfig) { ...@@ -189,13 +189,18 @@ export function transformBoardConfig(boardConfig) {
updateScopeObject('milestone_title', milestoneTitle); updateScopeObject('milestone_title', milestoneTitle);
} }
let { iterationTitle } = boardConfig; const { iterationId } = boardConfig;
if (boardConfig.iterationId === IterationIDs.NONE) { if (iterationId === IterationIDs.NONE) {
iterationTitle = IterationFilterType.none; updateScopeObject('iteration_id', IterationFilterType.none);
} else if (iterationId === IterationIDs.CURRENT) {
updateScopeObject('iteration_id', IterationFilterType.current);
} else if (iterationId) {
updateScopeObject('iteration_id', getIdFromGraphQLId(iterationId));
} }
if (iterationTitle) { const { iterationCadenceId } = boardConfig;
updateScopeObject('iteration_id', iterationTitle); if (iterationCadenceId) {
updateScopeObject('iteration_cadence_id', getIdFromGraphQLId(iterationCadenceId));
} }
let { weight } = boardConfig; let { weight } = boardConfig;
...@@ -259,6 +264,9 @@ export const FiltersInfo = { ...@@ -259,6 +264,9 @@ export const FiltersInfo = {
return valList[valList.length - 1].toUpperCase(); return valList[valList.length - 1].toUpperCase();
}, },
}, },
iterationCadenceId: {
negatedSupport: false,
},
weight: { weight: {
negatedSupport: true, negatedSupport: true,
}, },
......
...@@ -27,6 +27,7 @@ export default { ...@@ -27,6 +27,7 @@ export default {
: null, : null,
milestoneId: this.board.milestone?.id || null, milestoneId: this.board.milestone?.id || null,
iterationId: this.board.iteration?.id || null, iterationId: this.board.iteration?.id || null,
iterationCadenceId: this.board.iterationCadenceId || null,
}; };
}, },
boardScopeMutationVariables() { boardScopeMutationVariables() {
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { __ } from '~/locale'; import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AssigneeSelect from './assignee_select.vue'; import AssigneeSelect from './assignee_select.vue';
import BoardScopeCurrentIteration from './board_scope_current_iteration.vue'; import BoardScopeCurrentIteration from './board_scope_current_iteration.vue';
import BoardLabelsSelect from './labels_select.vue'; import BoardLabelsSelect from './labels_select.vue';
import BoardIterationSelect from './iteration_select.vue';
import BoardMilestoneSelect from './milestone_select.vue'; import BoardMilestoneSelect from './milestone_select.vue';
import BoardWeightSelect from './weight_select.vue'; import BoardWeightSelect from './weight_select.vue';
...@@ -11,10 +13,12 @@ export default { ...@@ -11,10 +13,12 @@ export default {
components: { components: {
AssigneeSelect, AssigneeSelect,
BoardLabelsSelect, BoardLabelsSelect,
BoardIterationSelect,
BoardMilestoneSelect, BoardMilestoneSelect,
BoardScopeCurrentIteration, BoardScopeCurrentIteration,
BoardWeightSelect, BoardWeightSelect,
}, },
mixins: [glFeatureFlagMixin()],
props: { props: {
collapseScope: { collapseScope: {
type: Boolean, type: Boolean,
...@@ -87,8 +91,15 @@ export default { ...@@ -87,8 +91,15 @@ export default {
@set-milestone="$emit('set-milestone', $event)" @set-milestone="$emit('set-milestone', $event)"
/> />
<board-iteration-select
v-if="isIssueBoard && glFeatures.iterationCadences"
:board="board"
:can-edit="canAdminBoard"
@set-iteration="$emit('set-iteration', $event)"
/>
<board-scope-current-iteration <board-scope-current-iteration
v-if="isIssueBoard" v-if="isIssueBoard && !glFeatures.iterationCadences"
:can-admin-board="canAdminBoard" :can-admin-board="canAdminBoard"
:iteration-id="iterationId" :iteration-id="iterationId"
@set-iteration="$emit('set-iteration', $event)" @set-iteration="$emit('set-iteration', $event)"
......
<script> <script>
import { GlFormCheckbox } from '@gitlab/ui'; import { GlFormCheckbox } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { IterationIDs } from '../constants'; import { IterationIDs, CURRENT_ITERATION } from '../constants';
export default { export default {
i18n: { i18n: {
...@@ -30,8 +30,8 @@ export default { ...@@ -30,8 +30,8 @@ export default {
methods: { methods: {
handleToggle() { handleToggle() {
this.checked = !this.checked; this.checked = !this.checked;
const iterationId = this.checked ? IterationIDs.CURRENT : null; const iteration = this.checked ? CURRENT_ITERATION : { id: null };
this.$emit('set-iteration', iterationId); this.$emit('set-iteration', iteration);
}, },
}, },
}; };
......
<script>
import { GlButton } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import searchIterationQuery from 'ee/issues/list/queries/search_iterations.query.graphql';
import { getIterationPeriod } from 'ee/iterations/utils';
import { n__, s__, __ } from '~/locale';
import { TYPE_ITERATION } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue';
import { IterationsPreset, ANY_ITERATION } from '../constants';
export default {
IterationsPreset,
components: {
GlButton,
DropdownWidget,
},
inject: ['fullPath'],
props: {
board: {
type: Object,
required: true,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
search: '',
iterations: [],
selected: this.board.iteration
? {
...this.board.iteration,
id: convertToGraphQLId(TYPE_ITERATION, getIdFromGraphQLId(this.board.iteration?.id)),
}
: null,
isEditing: false,
isDropdownShowing: false,
};
},
apollo: {
iterations: {
query: searchIterationQuery,
variables() {
return {
fullPath: this.fullPath,
search: this.search,
isProject: this.isProjectBoard,
};
},
skip() {
return !this.isEditing;
},
update(data) {
const boardType = this.isProjectBoard ? 'project' : 'group';
return data[boardType]?.iterations?.nodes || [];
},
error() {
this.setError({ message: this.$options.i18n.errorSearchingIterations });
},
},
},
computed: {
...mapGetters(['isProjectBoard']),
anyIteration() {
return this.selected.id === ANY_ITERATION.id;
},
iterationTitle() {
return this.anyIteration ? ANY_ITERATION.title : this.selected.title;
},
iterationTitleClass() {
return this.anyIteration ? 'gl-text-secondary' : 'gl-font-weight-bold';
},
isLoading() {
return this.$apollo.queries.iterations.loading;
},
iterationsByCadence() {
const cadences = [];
this.iterations.forEach((iteration) => {
if (!iteration.iterationCadence) {
return;
}
const { title, durationInWeeks, id } = iteration.iterationCadence;
const cadenceIteration = {
key: `${iteration.iterationCadence.id}-${iteration.id}`,
id: iteration.id,
title: this.iterationOptionText(iteration),
iterationCadenceId: id,
};
const cadence = cadences.find((cad) => cad.title === title);
if (cadence) {
cadence.options.push(cadenceIteration);
} else {
const durationText = durationInWeeks
? n__('Every week', 'Every %d weeks', durationInWeeks)
: null;
cadences.push({
id,
title,
secondaryText: durationText,
options: [cadenceIteration],
});
}
});
return cadences;
},
},
created() {
if (isEmpty(this.board.iteration)) {
this.selected = ANY_ITERATION;
}
},
methods: {
...mapActions(['setError']),
selectIteration(iteration) {
this.selected = iteration;
this.toggleEdit();
this.$emit(
'set-iteration',
iteration?.id !== ANY_ITERATION.id ? iteration : { id: null, iterationCadenceId: null },
);
},
toggleEdit() {
if (!this.isEditing && !this.isDropdownShowing) {
this.isEditing = true;
this.showDropdown();
} else {
this.hideDropdown();
}
},
showDropdown() {
this.$refs.editDropdown.showDropdown();
this.isDropdownShowing = true;
},
hideDropdown() {
this.isEditing = false;
this.isDropdownShowing = false;
},
setSearch(search) {
this.search = search;
},
iterationOptionText(iteration) {
return iteration.title
? `${iteration.title}: ${getIterationPeriod(iteration)}`
: getIterationPeriod(iteration);
},
},
i18n: {
label: s__('BoardScope|Iteration'),
errorSearchingIterations: s__(
'BoardScope|An error occurred while getting iterations. Please try again.',
),
searchIterations: s__('BoardScope|Search iterations'),
selectIteration: s__('BoardScope|Select iteration'),
edit: __('Edit'),
},
};
</script>
<template>
<div class="block iteration">
<div class="title gl-mb-3">
{{ $options.i18n.label }}
<gl-button
v-if="canEdit"
category="tertiary"
size="small"
class="edit-link float-right"
@click="toggleEdit"
>
{{ $options.i18n.edit }}
</gl-button>
</div>
<div v-if="!isEditing" :class="iterationTitleClass" data-testid="selected-iteration">
{{ iterationTitle }}
</div>
<dropdown-widget
v-show="isEditing"
ref="editDropdown"
:select-text="$options.i18n.selectIteration"
:search-text="$options.i18n.searchIterations"
:preset-options="$options.IterationsPreset"
:grouped-options="iterationsByCadence"
:is-loading="isLoading"
:selected="selected"
:search-term="search"
@hide="hideDropdown"
@set-option="selectIteration"
@set-search="setSearch"
/>
</div>
</template>
...@@ -31,6 +31,7 @@ export const FilterFields = { ...@@ -31,6 +31,7 @@ export const FilterFields = {
'iterationId', 'iterationId',
'iterationTitle', 'iterationTitle',
'iterationWildcardId', 'iterationWildcardId',
'iterationCadenceId',
], ],
[issuableTypes.epic]: ['authorUsername', 'labelName', 'search', 'myReactionEmoji'], [issuableTypes.epic]: ['authorUsername', 'labelName', 'search', 'myReactionEmoji'],
}; };
...@@ -46,6 +47,26 @@ export const IterationIDs = { ...@@ -46,6 +47,26 @@ export const IterationIDs = {
CURRENT: 'gid://gitlab/Iteration/-4', CURRENT: 'gid://gitlab/Iteration/-4',
}; };
export const ANY_ITERATION = {
id: 'gid://gitlab/Iteration/-1',
title: s__('BoardScope|Any iteration'),
iterationCadenceId: null,
};
export const NO_ITERATION = {
id: 'gid://gitlab/Iteration/0',
title: s__('BoardScope|No iteration'),
iterationCadenceId: null,
};
export const CURRENT_ITERATION = {
id: 'gid://gitlab/Iteration/-4',
title: s__('BoardScope|Current iteration'),
iterationCadenceId: null,
};
export const IterationsPreset = [ANY_ITERATION, NO_ITERATION, CURRENT_ITERATION];
export const MilestoneFilterType = { export const MilestoneFilterType = {
any: 'Any', any: 'Any',
none: 'None', none: 'None',
......
...@@ -22,5 +22,10 @@ fragment BoardScopeFragment on Board { ...@@ -22,5 +22,10 @@ fragment BoardScopeFragment on Board {
iteration { iteration {
...Iteration ...Iteration
} }
iterationCadence {
id
title
durationInWeeks
}
weight weight
} }
...@@ -6,5 +6,6 @@ fragment Iteration on Iteration { ...@@ -6,5 +6,6 @@ fragment Iteration on Iteration {
iterationCadence { iterationCadence {
id id
title title
durationInWeeks
} }
} }
...@@ -278,7 +278,62 @@ RSpec.describe 'Scoped issue boards', :js do ...@@ -278,7 +278,62 @@ RSpec.describe 'Scoped issue boards', :js do
end end
end end
context 'iteration' do context 'iteration - iteration_cadences FF on' do
let_it_be(:cadence) { create(:iterations_cadence, group: group, active: true, duration_in_weeks: 1, title: 'one week iterations') }
let_it_be(:iteration) { create(:current_iteration, :skip_future_date_validation, iterations_cadence: cadence, title: 'one test', group: group, start_date: 1.day.ago, due_date: Date.today) }
before do
stub_feature_flags(iteration_cadences: true)
visit project_boards_path(project)
wait_for_requests
end
it 'sets board iteration' do
update_board_iteration(iteration.title)
expect(find('.gl-filtered-search-scrollable')).to have_content(cadence.title)
expect(page).to have_selector('.board-card', count: 0)
end
it 'sets board to any iteration' do
update_board_iteration('Any iteration')
expect(find('.gl-filtered-search-scrollable')).not_to have_content(iteration.title)
expect(page).to have_selector('.board', count: 2)
expect(all('.board').first).to have_selector('.board-card', count: 2)
expect(all('.board').last).to have_selector('.board-card', count: 1)
end
it 'sets board to current iteration' do
update_board_iteration('Current')
expect(find('.gl-filtered-search-scrollable')).not_to have_content(iteration.title)
expect(find('.gl-filtered-search-scrollable')).to have_content('Current')
expect(all('.board')[1]).to have_selector('.board-card', count: 0)
end
it 'does not display iteration in search hint' do
update_board_iteration(iteration.title)
filtered_search.click
page.within('.gl-filtered-search-suggestion-list') do
expect(page).to have_content(_('Label'))
expect(page).not_to have_content(_('Iteration'))
end
end
end
context 'iteration - iteration_cadences FF off' do
before do
stub_feature_flags(iteration_cadences: false)
visit project_boards_path(project)
wait_for_requests
end
context 'group with iterations' do context 'group with iterations' do
let_it_be(:cadence) { create(:iterations_cadence, group: group) } let_it_be(:cadence) { create(:iterations_cadence, group: group) }
let_it_be(:iteration) { create(:current_iteration, :skip_future_date_validation, iterations_cadence: cadence, group: group, start_date: 1.day.ago, due_date: Date.today) } let_it_be(:iteration) { create(:current_iteration, :skip_future_date_validation, iterations_cadence: cadence, group: group, start_date: 1.day.ago, due_date: Date.today) }
...@@ -296,10 +351,6 @@ RSpec.describe 'Scoped issue boards', :js do ...@@ -296,10 +351,6 @@ RSpec.describe 'Scoped issue boards', :js do
end end
context 'board scoped to current iteration' do context 'board scoped to current iteration' do
before do
stub_feature_flags(iteration_cadences: false)
end
it 'adds current iteration to new issues' do it 'adds current iteration to new issues' do
update_board_scope('current_iteration', true) update_board_scope('current_iteration', true)
...@@ -583,6 +634,10 @@ RSpec.describe 'Scoped issue boards', :js do ...@@ -583,6 +634,10 @@ RSpec.describe 'Scoped issue boards', :js do
update_board_scope('weight', weight.to_s) update_board_scope('weight', weight.to_s)
end end
def update_board_iteration(iteration_title)
update_board_scope('iteration', iteration_title)
end
def create_board_scope(filter, value) def create_board_scope(filter, value)
click_on_create_new_board click_on_create_new_board
find('#board-new-name').set 'test' find('#board-new-name').set 'test'
......
...@@ -181,13 +181,14 @@ describe('transformBoardConfig', () => { ...@@ -181,13 +181,14 @@ describe('transformBoardConfig', () => {
{ id: 6, title: 'On hold', color: '#34ebec', type: 'GroupLabel', textColor: '#333333' }, { id: 6, title: 'On hold', color: '#34ebec', type: 'GroupLabel', textColor: '#333333' },
], ],
weight: 0, weight: 0,
iterationId: 'gid://gitlab/Iteration/1',
}; };
it('formats url parameters from boardConfig object', () => { it('formats url parameters from boardConfig object', () => {
const result = transformBoardConfig(boardConfig); const result = transformBoardConfig(boardConfig);
expect(result).toBe( expect(result).toBe(
'milestone_title=milestone&weight=0&assignee_username=username&label_name[]=Deliverable&label_name[]=On%20hold', 'milestone_title=milestone&iteration_id=1&weight=0&assignee_username=username&label_name[]=Deliverable&label_name[]=On%20hold',
); );
}); });
...@@ -195,6 +196,8 @@ describe('transformBoardConfig', () => { ...@@ -195,6 +196,8 @@ describe('transformBoardConfig', () => {
setWindowLocation('?label_name[]=Deliverable&label_name[]=On%20hold'); setWindowLocation('?label_name[]=Deliverable&label_name[]=On%20hold');
const result = transformBoardConfig(boardConfig); const result = transformBoardConfig(boardConfig);
expect(result).toBe('milestone_title=milestone&weight=0&assignee_username=username'); expect(result).toBe(
'milestone_title=milestone&iteration_id=1&weight=0&assignee_username=username',
);
}); });
}); });
import { GlModal } from '@gitlab/ui'; import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BoardForm from 'ee/boards/components/board_form.vue'; import BoardForm from 'ee/boards/components/board_form.vue';
import createEpicBoardMutation from 'ee/boards/graphql/epic_board_create.mutation.graphql'; import createEpicBoardMutation from 'ee/boards/graphql/epic_board_create.mutation.graphql';
import destroyEpicBoardMutation from 'ee/boards/graphql/epic_board_destroy.mutation.graphql'; import destroyEpicBoardMutation from 'ee/boards/graphql/epic_board_destroy.mutation.graphql';
...@@ -28,6 +28,8 @@ const currentBoard = { ...@@ -28,6 +28,8 @@ const currentBoard = {
labels: [], labels: [],
milestone: {}, milestone: {},
assignee: {}, assignee: {},
iteration: {},
iterationCadence: {},
weight: null, weight: null,
hideBacklogList: false, hideBacklogList: false,
hideClosedList: false, hideClosedList: false,
...@@ -51,8 +53,8 @@ describe('BoardForm', () => { ...@@ -51,8 +53,8 @@ describe('BoardForm', () => {
const findModal = () => wrapper.findComponent(GlModal); const findModal = () => wrapper.findComponent(GlModal);
const findModalActionPrimary = () => findModal().props('actionPrimary'); const findModalActionPrimary = () => findModal().props('actionPrimary');
const findFormWrapper = () => wrapper.find('[data-testid="board-form-wrapper"]'); const findFormWrapper = () => wrapper.findByTestId('board-form-wrapper');
const findDeleteConfirmation = () => wrapper.find('[data-testid="delete-confirmation-message"]'); const findDeleteConfirmation = () => wrapper.findByTestId('delete-confirmation-message');
const findInput = () => wrapper.find('#board-new-name'); const findInput = () => wrapper.find('#board-new-name');
const createStore = ({ getters = {} } = {}) => { const createStore = ({ getters = {} } = {}) => {
...@@ -67,11 +69,12 @@ describe('BoardForm', () => { ...@@ -67,11 +69,12 @@ describe('BoardForm', () => {
}); });
}; };
const createComponent = (props) => { const createComponent = ({ props, iterationCadences = false } = {}) => {
wrapper = shallowMount(BoardForm, { wrapper = shallowMountExtended(BoardForm, {
propsData: { ...defaultProps, ...props }, propsData: { ...defaultProps, ...props },
provide: { provide: {
rootPath: 'root', rootPath: 'root',
glFeatures: { iterationCadences },
}, },
mocks: { mocks: {
$apollo: { $apollo: {
...@@ -97,7 +100,7 @@ describe('BoardForm', () => { ...@@ -97,7 +100,7 @@ describe('BoardForm', () => {
describe('on non-scoped-board', () => { describe('on non-scoped-board', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ canAdminBoard: true, currentPage: formType.new }); createComponent({ props: { canAdminBoard: true, currentPage: formType.new } });
}); });
it('clears the form', () => { it('clears the form', () => {
...@@ -140,7 +143,7 @@ describe('BoardForm', () => { ...@@ -140,7 +143,7 @@ describe('BoardForm', () => {
}); });
it('does not call API if board name is empty', async () => { it('does not call API if board name is empty', async () => {
createComponent({ canAdminBoard: true, currentPage: formType.new }); createComponent({ props: { canAdminBoard: true, currentPage: formType.new } });
findInput().trigger('keyup.enter', { metaKey: true }); findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises(); await waitForPromises();
...@@ -149,7 +152,7 @@ describe('BoardForm', () => { ...@@ -149,7 +152,7 @@ describe('BoardForm', () => {
}); });
it('calls a correct GraphQL mutation and redirects to correct page from existing board', async () => { it('calls a correct GraphQL mutation and redirects to correct page from existing board', async () => {
createComponent({ canAdminBoard: true, currentPage: formType.new }); createComponent({ props: { canAdminBoard: true, currentPage: formType.new } });
fillForm(); fillForm();
await waitForPromises(); await waitForPromises();
...@@ -169,7 +172,7 @@ describe('BoardForm', () => { ...@@ -169,7 +172,7 @@ describe('BoardForm', () => {
it('shows a GlAlert if GraphQL mutation fails', async () => { it('shows a GlAlert if GraphQL mutation fails', async () => {
mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
createComponent({ canAdminBoard: true, currentPage: formType.new }); createComponent({ props: { canAdminBoard: true, currentPage: formType.new } });
jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
fillForm(); fillForm();
...@@ -202,21 +205,69 @@ describe('BoardForm', () => { ...@@ -202,21 +205,69 @@ describe('BoardForm', () => {
}); });
createComponent({ createComponent({
currentBoard: { props: {
...currentBoard, currentBoard: {
assignee: { ...currentBoard,
id: 1, assignee: {
}, id: 1,
milestone: { },
id: 'gid://gitlab/Milestone/2', milestone: {
id: 'gid://gitlab/Milestone/2',
},
iteration: {
id: 'gid://gitlab/Iteration/3',
},
}, },
iteration: { canAdminBoard: true,
id: 'gid://gitlab/Iteration/3', currentPage: formType.edit,
scopedIssueBoardFeatureEnabled: true,
},
});
findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises();
expect(mutate).toHaveBeenCalledWith({
mutation: updateBoardMutation,
variables: {
input: expect.objectContaining({
id: currentBoard.id,
assigneeId: 'gid://gitlab/User/1',
milestoneId: 'gid://gitlab/Milestone/2',
iterationId: 'gid://gitlab/Iteration/3',
}),
},
});
});
it('should send iterationCadenceId when feature flag is on', async () => {
mutate = jest.fn().mockResolvedValue({
data: {
updateBoard: { board: { id: 'gid://gitlab/Board/321' } },
},
});
createComponent({
props: {
currentBoard: {
...currentBoard,
assignee: {
id: 1,
},
milestone: {
id: 'gid://gitlab/Milestone/2',
},
iteration: {
id: 'gid://gitlab/Iteration/3',
},
iterationCadenceId: 'gid://gitlab/Iterations::Cadence/4',
}, },
canAdminBoard: true,
currentPage: formType.edit,
scopedIssueBoardFeatureEnabled: true,
}, },
canAdminBoard: true, iterationCadences: true,
currentPage: formType.edit,
scopedIssueBoardFeatureEnabled: true,
}); });
findInput().trigger('keyup.enter', { metaKey: true }); findInput().trigger('keyup.enter', { metaKey: true });
...@@ -231,6 +282,7 @@ describe('BoardForm', () => { ...@@ -231,6 +282,7 @@ describe('BoardForm', () => {
assigneeId: 'gid://gitlab/User/1', assigneeId: 'gid://gitlab/User/1',
milestoneId: 'gid://gitlab/Milestone/2', milestoneId: 'gid://gitlab/Milestone/2',
iterationId: 'gid://gitlab/Iteration/3', iterationId: 'gid://gitlab/Iteration/3',
iterationCadenceId: 'gid://gitlab/Iterations::Cadence/4',
}), }),
}, },
}); });
...@@ -251,9 +303,11 @@ describe('BoardForm', () => { ...@@ -251,9 +303,11 @@ describe('BoardForm', () => {
}, },
}); });
createComponent({ createComponent({
canAdminBoard: true, props: {
currentPage: formType.edit, canAdminBoard: true,
currentBoard: currentEpicBoard, currentPage: formType.edit,
currentBoard: currentEpicBoard,
},
}); });
findInput().trigger('keyup.enter', { metaKey: true }); findInput().trigger('keyup.enter', { metaKey: true });
...@@ -276,9 +330,11 @@ describe('BoardForm', () => { ...@@ -276,9 +330,11 @@ describe('BoardForm', () => {
it('shows a GlAlert if GraphQL mutation fails', async () => { it('shows a GlAlert if GraphQL mutation fails', async () => {
mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
createComponent({ createComponent({
canAdminBoard: true, props: {
currentPage: formType.edit, canAdminBoard: true,
currentBoard: currentEpicBoard, currentPage: formType.edit,
currentBoard: currentEpicBoard,
},
}); });
jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
findInput().trigger('keyup.enter', { metaKey: true }); findInput().trigger('keyup.enter', { metaKey: true });
...@@ -300,9 +356,11 @@ describe('BoardForm', () => { ...@@ -300,9 +356,11 @@ describe('BoardForm', () => {
it('passes correct primary action text and variant', () => { it('passes correct primary action text and variant', () => {
createComponent({ createComponent({
canAdminBoard: true, props: {
currentPage: formType.delete, canAdminBoard: true,
currentBoard: currentEpicBoard, currentPage: formType.delete,
currentBoard: currentEpicBoard,
},
}); });
expect(findModalActionPrimary().text).toBe('Delete'); expect(findModalActionPrimary().text).toBe('Delete');
expect(findModalActionPrimary().attributes[0].variant).toBe('danger'); expect(findModalActionPrimary().attributes[0].variant).toBe('danger');
...@@ -310,9 +368,11 @@ describe('BoardForm', () => { ...@@ -310,9 +368,11 @@ describe('BoardForm', () => {
it('renders delete confirmation message', () => { it('renders delete confirmation message', () => {
createComponent({ createComponent({
canAdminBoard: true, props: {
currentPage: formType.delete, canAdminBoard: true,
currentBoard: currentEpicBoard, currentPage: formType.delete,
currentBoard: currentEpicBoard,
},
}); });
expect(findDeleteConfirmation().exists()).toBe(true); expect(findDeleteConfirmation().exists()).toBe(true);
}); });
...@@ -320,9 +380,11 @@ describe('BoardForm', () => { ...@@ -320,9 +380,11 @@ describe('BoardForm', () => {
it('calls a correct GraphQL mutation and redirects to correct page after deleting board', async () => { it('calls a correct GraphQL mutation and redirects to correct page after deleting board', async () => {
mutate = jest.fn().mockResolvedValue({}); mutate = jest.fn().mockResolvedValue({});
createComponent({ createComponent({
canAdminBoard: true, props: {
currentPage: formType.delete, canAdminBoard: true,
currentBoard: currentEpicBoard, currentPage: formType.delete,
currentBoard: currentEpicBoard,
},
}); });
findModal().vm.$emit('primary'); findModal().vm.$emit('primary');
...@@ -342,9 +404,11 @@ describe('BoardForm', () => { ...@@ -342,9 +404,11 @@ describe('BoardForm', () => {
it('shows a GlAlert if GraphQL mutation fails', async () => { it('shows a GlAlert if GraphQL mutation fails', async () => {
mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
createComponent({ createComponent({
canAdminBoard: true, props: {
currentPage: formType.delete, canAdminBoard: true,
currentBoard: currentEpicBoard, currentPage: formType.delete,
currentBoard: currentEpicBoard,
},
}); });
jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
findModal().vm.$emit('primary'); findModal().vm.$emit('primary');
......
import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import IterationSelect from 'ee/boards/components/iteration_select.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { boardObj } from 'jest/boards/mock_data';
import defaultStore from '~/boards/stores';
import searchIterationQuery from 'ee/issues/list/queries/search_iterations.query.graphql';
import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue';
import { mockIterationsResponse, mockIterations } from './mock_data';
Vue.use(VueApollo);
describe('Iteration select component', () => {
let wrapper;
let fakeApollo;
const selectedText = () => wrapper.findByTestId('selected-iteration').text();
const findEditButton = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(DropdownWidget);
const iterationsQueryHandlerSuccess = jest.fn().mockResolvedValue(mockIterationsResponse);
const createStore = () => {
return new Vuex.Store({
...defaultStore,
getters: {
isGroupBoard: () => true,
isProjectBoard: () => false,
},
actions: {
setError: jest.fn(),
},
});
};
const createComponent = ({ props = {} } = {}) => {
const store = createStore();
fakeApollo = createMockApollo([[searchIterationQuery, iterationsQueryHandlerSuccess]]);
wrapper = shallowMountExtended(IterationSelect, {
store,
apolloProvider: fakeApollo,
propsData: {
board: boardObj,
canEdit: true,
...props,
},
provide: {
fullPath: 'gitlab-org',
},
stubs: {
GlDropdown,
GlDropdownItem,
},
});
// We need to mock out `showDropdown` which
// invokes `show` method of BDropdown used inside GlDropdown.
jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation();
};
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
describe('when not editing', () => {
beforeEach(() => {
createComponent();
});
it('defaults to Any iteration', () => {
expect(selectedText()).toContain('Any iteration');
});
it('skips the queries and does not render dropdown', () => {
expect(iterationsQueryHandlerSuccess).not.toHaveBeenCalled();
expect(findDropdown().isVisible()).toBe(false);
});
it('renders selected iteration', async () => {
findEditButton().vm.$emit('click');
findDropdown().vm.$emit('set-option', mockIterations[1]);
await nextTick();
expect(selectedText()).toContain(mockIterations[1].title);
});
it('shows Edit button if canEdit is true', () => {
expect(findEditButton().exists()).toBe(true);
});
});
describe('when editing', () => {
beforeEach(() => {
createComponent();
});
it('trigger query and renders dropdown with passed iterations', async () => {
findEditButton().vm.$emit('click');
await waitForPromises();
expect(iterationsQueryHandlerSuccess).toHaveBeenCalled();
expect(findDropdown().isVisible()).toBe(true);
expect(findDropdown().props('groupedOptions')).toHaveLength(2);
});
});
describe('canEdit', () => {
beforeEach(() => {
createComponent({ props: { canEdit: false } });
});
it('hides Edit button if false', () => {
expect(findEditButton().exists()).toBe(false);
});
});
});
...@@ -146,9 +146,12 @@ export const mockIterations = [ ...@@ -146,9 +146,12 @@ export const mockIterations = [
iterationCadence: { iterationCadence: {
id: 'gid://gitlab/Iterations::Cadence/1', id: 'gid://gitlab/Iterations::Cadence/1',
title: 'GitLab.org Iterations', title: 'GitLab.org Iterations',
durationInWeeks: 1,
__typename: 'IterationCadence',
}, },
startDate: '2021-10-05', startDate: '2021-10-05',
dueDate: '2021-10-10', dueDate: '2021-10-10',
__typename: 'Iteration',
}, },
{ {
id: 'gid://gitlab/Iteration/2', id: 'gid://gitlab/Iteration/2',
...@@ -156,12 +159,27 @@ export const mockIterations = [ ...@@ -156,12 +159,27 @@ export const mockIterations = [
iterationCadence: { iterationCadence: {
id: 'gid://gitlab/Iterations::Cadence/2', id: 'gid://gitlab/Iterations::Cadence/2',
title: 'GitLab.org Iterations: Volume II', title: 'GitLab.org Iterations: Volume II',
durationInWeeks: 2,
__typename: 'IterationCadence',
}, },
startDate: '2021-10-12', startDate: '2021-10-12',
dueDate: '2021-10-17', dueDate: '2021-10-17',
__typename: 'Iteration',
}, },
]; ];
export const mockIterationsResponse = {
data: {
group: {
id: 'gid://gitlab/Group/1',
iterations: {
nodes: mockIterations,
},
__typename: 'Group',
},
},
};
export const labels = [ export const labels = [
{ {
id: 'gid://gitlab/GroupLabel/5', id: 'gid://gitlab/GroupLabel/5',
......
...@@ -31,7 +31,7 @@ import { ...@@ -31,7 +31,7 @@ import {
mockIssue, mockIssue,
mockIssues, mockIssues,
mockEpic, mockEpic,
mockMilestones, mockIterations,
mockAssignees, mockAssignees,
mockSubGroups, mockSubGroups,
mockGroup0, mockGroup0,
...@@ -1108,7 +1108,7 @@ describe('fetchIterations', () => { ...@@ -1108,7 +1108,7 @@ describe('fetchIterations', () => {
data: { data: {
group: { group: {
iterations: { iterations: {
nodes: mockMilestones, nodes: mockIterations,
}, },
}, },
}, },
...@@ -1156,7 +1156,7 @@ describe('fetchIterations', () => { ...@@ -1156,7 +1156,7 @@ describe('fetchIterations', () => {
await actions.fetchIterations(store); await actions.fetchIterations(store);
expect(store.state.iterationsLoading).toBe(false); expect(store.state.iterationsLoading).toBe(false);
expect(store.state.iterations).toBe(mockMilestones); expect(store.state.iterations).toBe(mockIterations);
}); });
}); });
......
...@@ -5852,6 +5852,9 @@ msgstr "" ...@@ -5852,6 +5852,9 @@ msgstr ""
msgid "BoardNewIssue|Select a project" msgid "BoardNewIssue|Select a project"
msgstr "" msgstr ""
msgid "BoardScope|An error occurred while getting iterations. Please try again."
msgstr ""
msgid "BoardScope|An error occurred while getting milestones, please try again." msgid "BoardScope|An error occurred while getting milestones, please try again."
msgstr "" msgstr ""
...@@ -5867,6 +5870,9 @@ msgstr "" ...@@ -5867,6 +5870,9 @@ msgstr ""
msgid "BoardScope|Any assignee" msgid "BoardScope|Any assignee"
msgstr "" msgstr ""
msgid "BoardScope|Any iteration"
msgstr ""
msgid "BoardScope|Any label" msgid "BoardScope|Any label"
msgstr "" msgstr ""
...@@ -5876,24 +5882,39 @@ msgstr "" ...@@ -5876,24 +5882,39 @@ msgstr ""
msgid "BoardScope|Choose labels" msgid "BoardScope|Choose labels"
msgstr "" msgstr ""
msgid "BoardScope|Current iteration"
msgstr ""
msgid "BoardScope|Edit" msgid "BoardScope|Edit"
msgstr "" msgstr ""
msgid "BoardScope|Iteration"
msgstr ""
msgid "BoardScope|Labels" msgid "BoardScope|Labels"
msgstr "" msgstr ""
msgid "BoardScope|Milestone" msgid "BoardScope|Milestone"
msgstr "" msgstr ""
msgid "BoardScope|No iteration"
msgstr ""
msgid "BoardScope|No milestone" msgid "BoardScope|No milestone"
msgstr "" msgstr ""
msgid "BoardScope|Search iterations"
msgstr ""
msgid "BoardScope|Search milestones" msgid "BoardScope|Search milestones"
msgstr "" msgstr ""
msgid "BoardScope|Select assignee" msgid "BoardScope|Select assignee"
msgstr "" msgstr ""
msgid "BoardScope|Select iteration"
msgstr ""
msgid "BoardScope|Select labels" msgid "BoardScope|Select labels"
msgstr "" msgstr ""
......
import { GlModal } from '@gitlab/ui'; import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import setWindowLocation from 'helpers/set_window_location_helper'; import setWindowLocation from 'helpers/set_window_location_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import BoardForm from '~/boards/components/board_form.vue'; import BoardForm from '~/boards/components/board_form.vue';
...@@ -22,6 +22,8 @@ const currentBoard = { ...@@ -22,6 +22,8 @@ const currentBoard = {
labels: [], labels: [],
milestone: {}, milestone: {},
assignee: {}, assignee: {},
iteration: {},
iterationCadence: {},
weight: null, weight: null,
hideBacklogList: false, hideBacklogList: false,
hideClosedList: false, hideClosedList: false,
...@@ -37,11 +39,11 @@ describe('BoardForm', () => { ...@@ -37,11 +39,11 @@ describe('BoardForm', () => {
let wrapper; let wrapper;
let mutate; let mutate;
const findModal = () => wrapper.find(GlModal); const findModal = () => wrapper.findComponent(GlModal);
const findModalActionPrimary = () => findModal().props('actionPrimary'); const findModalActionPrimary = () => findModal().props('actionPrimary');
const findForm = () => wrapper.find('[data-testid="board-form"]'); const findForm = () => wrapper.findByTestId('board-form');
const findFormWrapper = () => wrapper.find('[data-testid="board-form-wrapper"]'); const findFormWrapper = () => wrapper.findByTestId('board-form-wrapper');
const findDeleteConfirmation = () => wrapper.find('[data-testid="delete-confirmation-message"]'); const findDeleteConfirmation = () => wrapper.findByTestId('delete-confirmation-message');
const findInput = () => wrapper.find('#board-new-name'); const findInput = () => wrapper.find('#board-new-name');
const store = createStore({ const store = createStore({
...@@ -52,7 +54,7 @@ describe('BoardForm', () => { ...@@ -52,7 +54,7 @@ describe('BoardForm', () => {
}); });
const createComponent = (props, data) => { const createComponent = (props, data) => {
wrapper = shallowMount(BoardForm, { wrapper = shallowMountExtended(BoardForm, {
propsData: { ...defaultProps, ...props }, propsData: { ...defaultProps, ...props },
data() { data() {
return { return {
......
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