Commit eb825062 authored by Peter Hegman's avatar Peter Hegman

Merge branch '354041-issue-search-cadence-any-current-iteration' into 'master'

Search board by Any and Current iteration cadence

See merge request gitlab-org/gitlab!83802
parents cc7b85de 266f5084
......@@ -4,7 +4,10 @@ import { mapActions } from 'vuex';
import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import {
FILTERED_SEARCH_TERM,
FILTER_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { AssigneeFilterType } from '~/boards/constants';
......@@ -42,6 +45,7 @@ export default {
search,
milestoneTitle,
iterationId,
iterationCadenceId,
types,
weight,
epicId,
......@@ -95,10 +99,20 @@ export default {
});
}
if (iterationId) {
let iterationData = null;
if (iterationId && iterationCadenceId) {
iterationData = `${iterationId}&${iterationCadenceId}`;
} else if (iterationCadenceId) {
iterationData = `${FILTER_ANY}&${iterationCadenceId}`;
} else if (iterationId) {
iterationData = iterationId;
}
if (iterationData) {
filteredSearchValue.push({
type: 'iteration',
value: { data: iterationId, operator: '=' },
value: { data: iterationData, operator: '=' },
});
}
......@@ -228,9 +242,12 @@ export default {
epicId,
myReactionEmoji,
iterationId,
iterationCadenceId,
releaseTag,
confidential,
} = this.filterParams;
let iteration = iterationId;
let cadence = iterationCadenceId;
let notParams = {};
if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
......@@ -251,6 +268,10 @@ export default {
);
}
if (iterationId?.includes('&')) {
[iteration, cadence] = iterationId.split('&');
}
return mapValues(
{
...notParams,
......@@ -259,7 +280,8 @@ export default {
assignee_username: assigneeUsername,
assignee_id: assigneeId,
milestone_title: milestoneTitle,
iteration_id: iterationId,
iteration_id: iteration,
iteration_cadence_id: cadence,
search,
types,
weight,
......
......@@ -5,6 +5,7 @@ import {
import {
TYPE_EPIC_BOARD,
TYPE_ITERATION,
TYPE_ITERATIONS_CADENCE,
TYPE_EPIC,
TYPE_MILESTONE,
TYPE_USER,
......@@ -66,7 +67,7 @@ function fullIterationCadenceId(id) {
return null;
}
return `gid://gitlab/Iterations::Cadence/${getIdFromGraphQLId(id)}`;
return convertToGraphQLId(TYPE_ITERATIONS_CADENCE, getIdFromGraphQLId(id));
}
export function fullUserId(userId) {
......@@ -266,6 +267,7 @@ export const FiltersInfo = {
},
iterationCadenceId: {
negatedSupport: false,
transform: (iterationCadenceId) => fullIterationCadenceId(iterationCadenceId),
},
weight: {
negatedSupport: true,
......
......@@ -62,6 +62,7 @@ export default {
token: IterationToken,
unique: true,
fetchIterations: this.fetchIterations,
fetchIterationCadences: this.fetchIterationCadences,
},
]
: []),
......@@ -78,7 +79,7 @@ export default {
},
},
methods: {
...mapActions(['fetchIterations']),
...mapActions(['fetchIterations', 'fetchIterationCadences']),
},
};
</script>
......@@ -17,6 +17,7 @@ import { historyPushState, convertObjectPropsToCamelCase } from '~/lib/utils/com
import { mergeUrlParams, removeParams, queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import searchIterationQuery from 'ee/issues/list/queries/search_iterations.query.graphql';
import searchIterationCadencesQuery from 'ee/issues/list/queries/search_iteration_cadences.query.graphql';
import {
fullEpicBoardId,
formatEpic,
......@@ -193,6 +194,41 @@ export default {
});
},
fetchIterationCadences({ state, commit }, title) {
commit(types.RECEIVE_CADENCES_REQUEST);
const { fullPath, boardType } = state;
const id = Number(title);
let variables = { fullPath, title, isProject: boardType === BoardType.project };
if (!Number.isNaN(id) && title !== '') {
variables = { fullPath, id, isProject: boardType === BoardType.project };
}
return gqlClient
.query({
query: searchIterationCadencesQuery,
variables,
})
.then(({ data }) => {
const errors = data[boardType]?.errors;
const cadences = data[boardType]?.iterationCadences?.nodes;
if (errors?.[0]) {
throw new Error(errors[0]);
}
commit(types.RECEIVE_CADENCES_SUCCESS, cadences);
return cadences;
})
.catch((e) => {
commit(types.RECEIVE_CADENCES_FAILURE);
throw e;
});
},
performSearch({ dispatch, getters }) {
dispatch(
'setFilters',
......
......@@ -26,3 +26,7 @@ export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
export const RECEIVE_ITERATIONS_REQUEST = 'RECEIVE_ITERATIONS_REQUEST';
export const RECEIVE_ITERATIONS_SUCCESS = 'RECEIVE_ITERATIONS_SUCCESS';
export const RECEIVE_ITERATIONS_FAILURE = 'RECEIVE_ITERATIONS_FAILURE';
export const RECEIVE_CADENCES_REQUEST = 'RECEIVE_CADENCES_REQUEST';
export const RECEIVE_CADENCES_SUCCESS = 'RECEIVE_CADENCES_SUCCESS';
export const RECEIVE_CADENCES_FAILURE = 'RECEIVE_CADENCES_FAILURE';
......@@ -119,6 +119,20 @@ export default {
state.error = __('Failed to load iterations.');
},
[mutationTypes.RECEIVE_CADENCES_REQUEST](state) {
state.iterationCadencesLoading = true;
},
[mutationTypes.RECEIVE_CADENCES_SUCCESS](state, cadences) {
state.iterationCadences = cadences;
state.iterationCadencesLoading = false;
},
[mutationTypes.RECEIVE_CADENCES_FAILURE](state) {
state.iterationCadencesLoading = false;
state.error = __('Failed to load iteration cadences.');
},
[mutationTypes.REQUEST_MORE_EPICS]: (state) => {
Vue.set(state, 'epicsSwimlanesFetchInProgress', {
...state.epicsSwimlanesFetchInProgress,
......
......@@ -38,6 +38,7 @@ export default {
token: IterationToken,
fetchIterations: this.fetchIterations,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-iteration`,
hideDefaultCadenceOptions: true,
});
}
......
query searchIterationCadences(
$fullPath: ID!
$title: String
$id: ID
$isProject: Boolean = false
) {
group(fullPath: $fullPath) @skip(if: $isProject) {
id
iterationCadences(title: $title, id: $id, includeAncestorGroups: true) {
nodes {
id
title
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
id
iterationCadences(title: $title, id: $id, includeAncestorGroups: true) {
nodes {
id
title
}
}
}
}
......@@ -25,7 +25,7 @@ export function groupByIterationCadences(iterations) {
if (!iteration.iterationCadence) {
return;
}
const { title } = iteration.iterationCadence;
const { title, id } = iteration.iterationCadence;
const cadenceIteration = {
id: iteration.id,
title: iteration.title,
......@@ -35,7 +35,7 @@ export function groupByIterationCadences(iterations) {
if (cadence) {
cadence.iterations.push(cadenceIteration);
} else {
cadences.push({ title, iterations: [cadenceIteration] });
cadences.push({ title, iterations: [cadenceIteration], id });
}
});
return cadences;
......
import { __ } from '~/locale';
import {
DEFAULT_LABEL_ANY,
DEFAULT_NONE_ANY,
FILTER_CURRENT,
} from '~/vue_shared/components/filtered_search_bar/constants';
......@@ -8,9 +9,10 @@ export * from '~/vue_shared/components/filtered_search_bar/constants';
export const WEIGHT_TOKEN_SUGGESTIONS_SIZE = 21;
export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([
{ value: FILTER_CURRENT, text: __('Current') },
]);
export const DEFAULT_CURRENT = { value: FILTER_CURRENT, text: __('Current') };
export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat(DEFAULT_CURRENT);
export const DEFAULT_CADENCES = [DEFAULT_LABEL_ANY, DEFAULT_CURRENT];
export const TOKEN_TITLE_ITERATION = __('Iteration');
export const TOKEN_TITLE_EPIC = __('Epic');
......
......@@ -2,12 +2,13 @@
import { GlDropdownDivider, GlDropdownSectionHeader, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { groupByIterationCadences, getIterationPeriod } from 'ee/iterations/utils';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ITERATIONS_CADENCE } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { OPERATOR_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import IterationTitle from 'ee/iterations/components/iteration_title.vue';
import { DEFAULT_ITERATIONS } from '../constants';
import { DEFAULT_CADENCES, DEFAULT_ITERATIONS } from '../constants';
export default {
components: {
......@@ -17,7 +18,6 @@ export default {
GlFilteredSearchSuggestion,
IterationTitle,
},
mixins: [glFeatureFlagMixin()],
props: {
active: {
type: Boolean,
......@@ -42,9 +42,23 @@ export default {
defaultIterations() {
return this.config.defaultIterations || DEFAULT_ITERATIONS;
},
defaultCadenceOptions() {
return !this.config.hideDefaultCadenceOptions && this.value.operator === OPERATOR_IS
? DEFAULT_CADENCES
: [];
},
},
methods: {
getActiveIteration(iterations, data) {
if (data?.includes('&')) {
const iterationCadenceId = this.getIterationCadenceId(data);
const iteration = iterations.find(
(i) =>
i?.iterationCadence?.id ===
convertToGraphQLId(TYPE_ITERATIONS_CADENCE, iterationCadenceId),
);
return iteration?.iterationCadence;
}
return iterations.find((iteration) => this.getId(iteration) === data);
},
groupIterationsByCadence(iterations) {
......@@ -52,25 +66,55 @@ export default {
},
fetchIterations(searchTerm) {
this.loading = true;
this.config
.fetchIterations(searchTerm)
.then((response) => {
this.iterations = Array.isArray(response) ? response : response.data;
})
.catch(() => {
createFlash({ message: __('There was a problem fetching iterations.') });
})
.finally(() => {
this.loading = false;
});
if (searchTerm?.includes('&')) {
this.config
.fetchIterationCadences(this.getIterationCadenceId(searchTerm))
.then((response) => {
this.iterations = [
{
iterationCadence: response[0],
},
];
})
.catch((error) => {
createFlash({ message: this.$options.i18n.errorMessage, captureError: true, error });
})
.finally(() => {
this.loading = false;
});
} else {
this.config
.fetchIterations(searchTerm)
.then((response) => {
this.iterations = Array.isArray(response) ? response : response.data;
})
.catch(() => {
createFlash({ message: this.$options.i18n.errorMessage });
})
.finally(() => {
this.loading = false;
});
}
},
getId(option) {
return getIdFromGraphQLId(option.id).toString();
},
getId(iteration) {
return getIdFromGraphQLId(iteration.id).toString();
getIterationCadenceId(input) {
return input.split('&')[1];
},
iterationTokenText(iteration) {
const cadenceTitle = iteration.iterationCadence.title;
return `${cadenceTitle} ${getIterationPeriod(iteration)}`;
getIterationOption(input) {
return input.split('&')[0];
},
iterationTokenText(iterationOrCadence, inputValue) {
if (iterationOrCadence?.id?.includes(TYPE_ITERATIONS_CADENCE)) {
return `${this.getIterationOption(inputValue)}::${iterationOrCadence.title}`;
}
const cadenceTitle = iterationOrCadence.iterationCadence.title;
return `${cadenceTitle} ${getIterationPeriod(iterationOrCadence)}`;
},
},
i18n: {
errorMessage: __('There was a problem fetching iterations.'),
},
};
</script>
......@@ -88,7 +132,7 @@ export default {
v-on="$listeners"
>
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
{{ activeTokenValue ? iterationTokenText(activeTokenValue) : inputValue }}
{{ activeTokenValue ? iterationTokenText(activeTokenValue, inputValue) : inputValue }}
</template>
<template #suggestions-list="{ suggestions }">
<template v-for="(cadence, index) in groupIterationsByCadence(suggestions)">
......@@ -100,6 +144,13 @@ export default {
>
{{ cadence.title }}
</gl-dropdown-section-header>
<gl-filtered-search-suggestion
v-for="option in defaultCadenceOptions"
:key="`${option.value}-${index}`"
:value="`${option.value}&${getId(cadence)}`"
>
{{ option.text }}
</gl-filtered-search-suggestion>
<gl-filtered-search-suggestion
v-for="iteration in cadence.iterations"
:key="iteration.id"
......
......@@ -54,9 +54,9 @@ RSpec.describe 'Issue board filters', :js do
it 'loads all the iterations when opened and submit one as filter', :aggregate_failures do
expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2)
# 4 dropdown items must be shown
# None, Any, Current and iteration
expect_filtered_search_dropdown_results(filter_dropdown, 4)
# 6 dropdown items must be shown
# None, Any, Current, iteration, Any and Current within cadence
expect_filtered_search_dropdown_results(filter_dropdown, 6)
click_on iteration.period
filter_submit.click
......
......@@ -54,6 +54,7 @@ describe('IssueBoardFilter', () => {
fetchAuthorsSpy,
wrapper.vm.fetchMilestones,
wrapper.vm.fetchIterations,
wrapper.vm.fetchIterationCadences,
);
expect(wrapper.findComponent(BoardFilteredSearch).props('tokens')).toEqual(
......
......@@ -180,6 +180,17 @@ export const mockIterationsResponse = {
},
};
export const mockIterationCadences = [
{
id: 'gid://gitlab/Iterations::Cadence/11',
title: 'Cadence 1',
},
{
id: 'gid://gitlab/Iterations::Cadence/22',
title: 'Cadence 2',
},
];
export const labels = [
{
id: 'gid://gitlab/GroupLabel/5',
......@@ -446,7 +457,13 @@ export const mockGroup2 = {
export const mockSubGroups = [mockGroup0, mockGroup1, mockGroup2];
export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, fetchIterations) => [
export const mockTokens = (
fetchLabels,
fetchAuthors,
fetchMilestones,
fetchIterations,
fetchIterationCadences,
) => [
{
icon: 'user',
title: __('Assignee'),
......@@ -556,6 +573,7 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, fetchIter
],
unique: true,
fetchIterations,
fetchIterationCadences,
token: IterationToken,
},
{
......
......@@ -10,6 +10,7 @@ import {
IterationIDs,
} from 'ee/boards/constants';
import epicCreateMutation from 'ee/boards/graphql/epic_create.mutation.graphql';
import searchIterationCadencesQuery from 'ee/issues/list/queries/search_iteration_cadences.query.graphql';
import actions, { gqlClient } from 'ee/boards/stores/actions';
import * as types from 'ee/boards/stores/mutation_types';
import mutations from 'ee/boards/stores/mutations';
......@@ -25,6 +26,7 @@ import * as typesCE from '~/boards/stores/mutation_types';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import * as commonUtils from '~/lib/utils/common_utils';
import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import {
labels,
mockLists,
......@@ -35,6 +37,7 @@ import {
mockAssignees,
mockSubGroups,
mockGroup0,
mockIterationCadences,
} from '../mock_data';
Vue.use(Vuex);
......@@ -1163,6 +1166,106 @@ describe('fetchIterations', () => {
});
});
describe('fetchIterationCadences', () => {
const queryResponse = {
data: {
group: {
iterationCadences: {
nodes: mockIterationCadences,
},
},
},
};
const queryErrors = {
data: {
group: {
errors: ['You cannot view these iteration cadences'],
iterationCadences: {},
},
},
};
function createStore({
state = {
boardType: 'group',
fullPath: 'gitlab-org/gitlab',
iterationCadences: [],
iterationCadencesLoading: false,
},
} = {}) {
return new Vuex.Store({
state,
mutations,
});
}
it('sets iterationCadencesLoading to true', () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
actions.fetchIterationCadences(store);
expect(store.state.iterationCadencesLoading).toBe(true);
});
describe('success', () => {
it('with search by title - sets state.iterationCadences from query result', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
await actions.fetchIterationCadences(store, 'search');
expect(store.state.iterationCadencesLoading).toBe(false);
expect(store.state.iterationCadences).toBe(mockIterationCadences);
expect(gqlClient.query).toHaveBeenCalledWith({
query: searchIterationCadencesQuery,
variables: {
fullPath: 'gitlab-org/gitlab',
title: 'search',
isProject: false,
},
});
});
it('with search by id - sets state.iterationCadences from query result', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
await actions.fetchIterationCadences(store, '11');
expect(store.state.iterationCadencesLoading).toBe(false);
expect(store.state.iterationCadences).toBe(mockIterationCadences);
expect(gqlClient.query).toHaveBeenCalledWith({
query: searchIterationCadencesQuery,
variables: {
fullPath: 'gitlab-org/gitlab',
id: 11,
isProject: false,
},
});
});
});
describe('failure', () => {
it('throws an error and displays an error message', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryErrors);
const store = createStore();
await expect(actions.fetchIterationCadences(store)).rejects.toThrow();
expect(store.state.iterationCadencesLoading).toBe(false);
expect(store.state.error).toBe(__('Failed to load iteration cadences.'));
});
});
});
describe('fetchAssignees', () => {
const queryResponse = {
data: {
......
......@@ -224,6 +224,7 @@ export const mockIterationsWithCadences = [
startDate: '2021-11-23T12:34:56',
dueDate: '2021-11-30T12:34:56',
iterationCadence: {
id: 1,
title: 'cadence 1',
},
},
......@@ -233,6 +234,7 @@ export const mockIterationsWithCadences = [
startDate: '2021-11-23T12:34:56',
dueDate: '2021-11-30T12:34:56',
iterationCadence: {
id: 2,
title: 'cadence 2',
},
},
......@@ -242,6 +244,7 @@ export const mockIterationsWithCadences = [
startDate: '2021-11-23T12:34:56',
dueDate: '2021-11-30T12:34:56',
iterationCadence: {
id: 2,
title: 'cadence 2',
},
},
......@@ -251,6 +254,7 @@ export const mockIterationsWithCadences = [
startDate: '2021-11-23T12:34:56',
dueDate: '2021-11-30T12:34:56',
iterationCadence: {
id: 1,
title: 'cadence 1',
},
},
......
......@@ -15,6 +15,7 @@ describe('groupByIterationCadences', () => {
const period = 'Nov 23, 2021 - Nov 30, 2021';
const expected = [
{
id: 1,
title: 'cadence 1',
iterations: [
{ id: 1, title: 'iteration 1', period },
......@@ -22,6 +23,7 @@ describe('groupByIterationCadences', () => {
],
},
{
id: 2,
title: 'cadence 2',
iterations: [
{ id: 2, title: 'iteration 2', period },
......
......@@ -73,6 +73,19 @@ describe('IterationToken', () => {
expect(fetchIterationsSpy).toHaveBeenCalledWith(search);
});
it('fetches iteration cadences when cadence is set', () => {
const search = 'Current&1';
const fetchIterationCadencesSpy = jest.fn().mockResolvedValue();
wrapper = createComponent({
config: { ...mockIterationToken, fetchIterationCadences: fetchIterationCadencesSpy },
});
wrapper.findComponent(GlFilteredSearchToken).vm.$emit('input', { data: search });
expect(fetchIterationCadencesSpy).toHaveBeenCalledWith('1');
});
it('renders error message when request fails', async () => {
const fetchIterationsSpy = jest.fn().mockRejectedValue();
......
......@@ -15460,6 +15460,9 @@ msgstr ""
msgid "Failed to load groups, users and deploy keys."
msgstr ""
msgid "Failed to load iteration cadences."
msgstr ""
msgid "Failed to load iterations."
msgstr ""
......
......@@ -124,7 +124,7 @@ describe('BoardFilteredSearch', () => {
{ type: 'milestone', value: { data: 'New Milestone', operator: '=' } },
{ type: 'type', value: { data: 'INCIDENT', operator: '=' } },
{ type: 'weight', value: { data: '2', operator: '=' } },
{ type: 'iteration', value: { data: '3341', operator: '=' } },
{ type: 'iteration', value: { data: 'Any&3', operator: '=' } },
{ type: 'release', value: { data: 'v1.0.0', operator: '=' } },
];
jest.spyOn(urlUtility, 'updateHistory');
......@@ -134,7 +134,7 @@ describe('BoardFilteredSearch', () => {
title: '',
replace: true,
url:
'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0',
'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=Any&iteration_cadence_id=3&types=INCIDENT&weight=2&release_tag=v1.0.0',
});
});
......
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