Commit a32387e5 authored by Jan Provaznik's avatar Jan Provaznik

Merge branch 'cngo-add-iteration-token-to-issues-list-refactor' into 'master'

Add iteration and weight filter token to issues list refactor

See merge request gitlab-org/gitlab!60148
parents 7c2ad0dc 2795812e
......@@ -36,8 +36,10 @@ import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/c
import { __ } from '~/locale';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
import eventHub from '../eventhub';
import IssueCardTimeInfo from './issue_card_time_info.vue';
......@@ -88,6 +90,9 @@ export default {
hasIssues: {
default: false,
},
hasIssueWeightsFeature: {
default: false,
},
initialEmail: {
default: '',
},
......@@ -103,6 +108,9 @@ export default {
newIssuePath: {
default: '',
},
projectIterationsPath: {
default: '',
},
projectLabelsPath: {
default: '',
},
......@@ -155,7 +163,7 @@ export default {
return convertToSearchQuery(this.filterTokens) || undefined;
},
searchTokens() {
return [
const tokens = [
{
type: 'author_username',
title: __('Author'),
......@@ -216,6 +224,30 @@ export default {
],
},
];
if (this.projectIterationsPath) {
tokens.push({
type: 'iteration',
title: __('Iteration'),
icon: 'iteration',
token: IterationToken,
unique: true,
defaultIterations: [],
fetchIterations: this.fetchIterations,
});
}
if (this.hasIssueWeightsFeature) {
tokens.push({
type: 'weight',
title: __('Weight'),
icon: 'weight',
token: WeightToken,
unique: true,
});
}
return tokens;
},
showPaginationControls() {
return this.issues.length > 0;
......@@ -273,6 +305,9 @@ export default {
fetchMilestones(search) {
return this.fetchWithCache(this.projectMilestonesPath, 'milestones', 'title', search, true);
},
fetchIterations(search) {
return axios.get(this.projectIterationsPath, { params: { search } });
},
fetchUsers(search) {
return axios.get(this.autocompleteUsersPath, { params: { search } });
},
......
......@@ -334,4 +334,24 @@ export const filters = {
[OPERATOR_IS]: 'confidential',
},
},
iteration: {
apiParam: {
[OPERATOR_IS]: 'iteration_title',
[OPERATOR_IS_NOT]: 'not[iteration_title]',
},
urlParam: {
[OPERATOR_IS]: 'iteration_title',
[OPERATOR_IS_NOT]: 'not[iteration_title]',
},
},
weight: {
apiParam: {
[OPERATOR_IS]: 'weight',
[OPERATOR_IS_NOT]: 'not[weight]',
},
urlParam: {
[OPERATOR_IS]: 'weight',
[OPERATOR_IS_NOT]: 'not[weight]',
},
},
};
......@@ -98,6 +98,7 @@ export function initIssuesListApp() {
maxAttachmentSize,
newIssuePath,
projectImportJiraPath,
projectIterationsPath,
projectLabelsPath,
projectMilestonesPath,
projectPath,
......@@ -128,6 +129,7 @@ export function initIssuesListApp() {
issuesPath,
jiraIntegrationPath,
newIssuePath,
projectIterationsPath,
projectLabelsPath,
projectMilestonesPath,
projectPath,
......
/* eslint-disable @gitlab/require-i18n-strings */
import { __ } from '~/locale';
export const DEBOUNCE_DELAY = 200;
const DEFAULT_LABEL_NO_LABEL = { value: 'No label', text: __('No label') };
export const DEFAULT_LABEL_NONE = { value: 'None', text: __('None') };
export const DEFAULT_LABEL_ANY = { value: 'Any', text: __('Any') };
export const DEFAULT_LABEL_CURRENT = { value: 'Current', text: __('Current') };
export const DEFAULT_LABELS = [DEFAULT_LABEL_NO_LABEL];
export const DEFAULT_ITERATIONS = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, DEFAULT_LABEL_CURRENT];
export const DEBOUNCE_DELAY = 200;
export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
};
export const DEFAULT_LABELS = [DEFAULT_LABEL_NO_LABEL];
export const DEFAULT_MILESTONES = [
DEFAULT_LABEL_NONE,
......@@ -21,4 +19,8 @@ export const DEFAULT_MILESTONES = [
{ value: 'Started', text: __('Started') },
];
export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
};
/* eslint-enable @gitlab/require-i18n-strings */
<script>
import {
GlDropdownDivider,
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY, DEFAULT_ITERATIONS } from '../constants';
export default {
components: {
GlDropdownDivider,
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
iterations: this.config.initialIterations || [],
defaultIterations: this.config.defaultIterations || DEFAULT_ITERATIONS,
loading: true,
};
},
computed: {
currentValue() {
return this.value.data;
},
activeIteration() {
return this.iterations.find((iteration) => iteration.title === this.currentValue);
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.iterations.length) {
this.fetchIterationBySearchTerm(this.currentValue);
}
},
},
},
methods: {
fetchIterationBySearchTerm(searchTerm) {
const fetchPromise = this.config.fetchPath
? this.config.fetchIterations(this.config.fetchPath, searchTerm)
: this.config.fetchIterations(searchTerm);
this.loading = true;
fetchPromise
.then((response) => {
this.iterations = Array.isArray(response) ? response : response.data;
})
.catch(() => createFlash({ message: __('There was a problem fetching iterations.') }))
.finally(() => {
this.loading = false;
});
},
searchIterations: debounce(function debouncedSearch({ data }) {
this.fetchIterationBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchIterations"
>
<template #view="{ inputValue }">
{{ activeIteration ? activeIteration.title : inputValue }}
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="iteration in defaultIterations"
:key="iteration.value"
:value="iteration.value"
>
{{ iteration.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultIterations.length" />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="iteration in iterations"
:key="iteration.title"
:value="iteration.title"
>
{{ iteration.title }}
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
<script>
import { GlDropdownDivider, GlFilteredSearchSuggestion, GlFilteredSearchToken } from '@gitlab/ui';
import { DEFAULT_LABEL_ANY, DEFAULT_LABEL_NONE } from '../constants';
export default {
baseWeights: ['0', '1', '2', '3', '4', '5'],
components: {
GlDropdownDivider,
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
weights: this.$options.baseWeights,
defaultWeights: this.config.defaultWeights || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY],
};
},
methods: {
updateWeights({ data }) {
const weight = parseInt(data, 10);
this.weights = Number.isNaN(weight) ? this.$options.baseWeights : [String(weight)];
},
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="updateWeights"
>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="weight in defaultWeights"
:key="weight.value"
:value="weight.value"
>
{{ weight.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultWeights.length" />
<gl-filtered-search-suggestion v-for="weight of weights" :key="weight" :value="weight">
{{ weight }}
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
</template>
......@@ -71,11 +71,17 @@ module EE
override :issues_list_data
def issues_list_data(project, current_user, finder)
super.merge!(
data = super.merge!(
has_blocked_issues_feature: project.feature_available?(:blocked_issues).to_s,
has_issuable_health_status_feature: project.feature_available?(:issuable_health_status).to_s,
has_issue_weights_feature: project.feature_available?(:issue_weights).to_s
)
if project.feature_available?(:iterations)
data[:project_iterations_path] = api_v4_projects_iterations_path(id: project.id)
end
data
end
end
end
......@@ -125,23 +125,49 @@ RSpec.describe EE::IssuesHelper do
end
describe '#issues_list_data' do
it 'returns expected result' do
current_user = double.as_null_object
finder = double.as_null_object
let(:current_user) { double.as_null_object }
let(:finder) { double.as_null_object }
before do
allow(helper).to receive(:current_user).and_return(current_user)
allow(helper).to receive(:finder).and_return(finder)
allow(helper).to receive(:can?).and_return(true)
allow(helper).to receive(:url_for).and_return('#')
allow(helper).to receive(:import_csv_namespace_project_issues_path).and_return('#')
allow(project).to receive(:feature_available?).and_return(true)
end
expected = {
has_blocked_issues_feature: 'true',
has_issuable_health_status_feature: 'true',
has_issue_weights_feature: 'true'
}
context 'when features are enabled' do
before do
stub_licensed_features(iterations: true, issue_weights: true, issuable_health_status: true, blocked_issues: true)
end
expect(helper.issues_list_data(project, current_user, finder)).to include(expected)
it 'returns data with licensed features enabled' do
expected = {
has_blocked_issues_feature: 'true',
has_issuable_health_status_feature: 'true',
has_issue_weights_feature: 'true',
project_iterations_path: api_v4_projects_iterations_path(id: project.id)
}
expect(helper.issues_list_data(project, current_user, finder)).to include(expected)
end
end
context 'when features are disabled' do
before do
stub_licensed_features(iterations: false, issue_weights: false, issuable_health_status: false, blocked_issues: false)
end
it 'returns data with licensed features disabled' do
expected = {
has_blocked_issues_feature: 'false',
has_issuable_health_status_feature: 'false',
has_issue_weights_feature: 'false'
}
result = helper.issues_list_data(project, current_user, finder)
expect(result).to include(expected)
expect(result).not_to include(:project_iterations_path)
end
end
end
end
......@@ -32189,6 +32189,9 @@ msgstr ""
msgid "There was a problem fetching groups."
msgstr ""
msgid "There was a problem fetching iterations."
msgstr ""
msgid "There was a problem fetching labels."
msgstr ""
......
......@@ -14,6 +14,10 @@ export const locationSearch = [
'not[label_name][]=drama',
'my_reaction_emoji=thumbsup',
'confidential=no',
'iteration_title=season:+%234',
'not[iteration_title]=season:+%2320',
'weight=1',
'not[weight]=3',
].join('&');
export const filteredTokens = [
......@@ -29,6 +33,10 @@ export const filteredTokens = [
{ type: 'labels', value: { data: 'drama', operator: OPERATOR_IS_NOT } },
{ type: 'my_reaction_emoji', value: { data: 'thumbsup', operator: OPERATOR_IS } },
{ type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } },
{ type: 'iteration', value: { data: 'season: #4', operator: OPERATOR_IS } },
{ type: 'iteration', value: { data: 'season: #20', operator: OPERATOR_IS_NOT } },
{ type: 'weight', value: { data: '1', operator: OPERATOR_IS } },
{ type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } },
{ type: 'filtered-search-term', value: { data: 'find' } },
{ type: 'filtered-search-term', value: { data: 'issues' } },
];
......@@ -44,6 +52,10 @@ export const apiParams = {
'not[labels]': 'live action,drama',
my_reaction_emoji: 'thumbsup',
confidential: 'no',
iteration_title: 'season: #4',
'not[iteration_title]': 'season: #20',
weight: '1',
'not[weight]': '3',
};
export const urlParams = {
......@@ -57,4 +69,8 @@ export const urlParams = {
'not[label_name][]': ['live action', 'drama'],
my_reaction_emoji: ['thumbsup'],
confidential: ['no'],
iteration_title: ['season: #4'],
'not[iteration_title]': ['season: #20'],
weight: ['1'],
'not[weight]': ['3'],
};
......@@ -5,8 +5,10 @@ import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/auth
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
export const mockAuthor1 = {
id: 1,
......@@ -98,6 +100,15 @@ export const mockAuthorToken = {
fetchAuthors: Api.projectUsers.bind(Api),
};
export const mockIterationToken = {
type: 'iteration',
icon: 'iteration',
title: 'Iteration',
unique: true,
token: IterationToken,
fetchIterations: () => Promise.resolve(),
};
export const mockLabelToken = {
type: 'label_name',
icon: 'labels',
......@@ -155,6 +166,14 @@ export const mockMembershipToken = {
],
};
export const mockWeightToken = {
type: 'weight',
icon: 'weight',
title: 'Weight',
unique: true,
token: WeightToken,
};
export const mockMembershipTokenOptionsWithoutTitles = {
...mockMembershipToken,
options: [{ value: 'exclude' }, { value: 'only' }],
......
import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import createFlash from '~/flash';
import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue';
import { mockIterationToken } from '../mock_data';
jest.mock('~/flash');
describe('IterationToken', () => {
const title = 'gitlab-org: #1';
let wrapper;
const createComponent = ({ config = mockIterationToken, value = { data: '' } } = {}) =>
mount(IterationToken, {
propsData: {
config,
value,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: 'custom-class',
},
});
afterEach(() => {
wrapper.destroy();
});
it('renders iteration value', async () => {
wrapper = createComponent({ value: { data: title } });
await wrapper.vm.$nextTick();
const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3); // `Iteration` `=` `gitlab-org: #1`
expect(tokenSegments.at(2).text()).toBe(title);
});
it('fetches initial values', () => {
const fetchIterationsSpy = jest.fn().mockResolvedValue();
wrapper = createComponent({
config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
value: { data: title },
});
expect(fetchIterationsSpy).toHaveBeenCalledWith(title);
});
it('fetches iterations on user input', () => {
const search = 'hello';
const fetchIterationsSpy = jest.fn().mockResolvedValue();
wrapper = createComponent({
config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
});
wrapper.findComponent(GlFilteredSearchToken).vm.$emit('input', { data: search });
expect(fetchIterationsSpy).toHaveBeenCalledWith(search);
});
it('renders error message when request fails', async () => {
const fetchIterationsSpy = jest.fn().mockRejectedValue();
wrapper = createComponent({
config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
});
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith({
message: 'There was a problem fetching iterations.',
});
});
});
import { GlFilteredSearchTokenSegment } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
import { mockWeightToken } from '../mock_data';
jest.mock('~/flash');
describe('WeightToken', () => {
const weight = '3';
let wrapper;
const createComponent = ({ config = mockWeightToken, value = { data: '' } } = {}) =>
mount(WeightToken, {
propsData: {
config,
value,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: 'custom-class',
},
});
afterEach(() => {
wrapper.destroy();
});
it('renders weight value', () => {
wrapper = createComponent({ value: { data: weight } });
const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3); // `Weight` `=` `3`
expect(tokenSegments.at(2).text()).toBe(weight);
});
});
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