diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
index 3ccf982ef019dab8100b6280bcb3e902bfbacacd..560f691eb4e0b41cf3f553c030365c9da8cb31c9 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -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 } });
},
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
index 60a211ec8c042c3c14316dfb62fde510b914e24e..38a0d9538358301c0eeb50dc73af5a98578cb1c2 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -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]',
+ },
+ },
};
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index 06c50a02ada64f4d5ec264ba9156e07905d02c92..0318c2c24846bce0e41c7154bf8737e7cc6aecb1 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -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,
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 3d8afd162cbebf79aa04241e053b170367feeba1..e2868879425195cd132cbb2c6bdd00df76e74048 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -1,18 +1,16 @@
/* 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 */
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7b6a590279a8894b9ebbdb5ba6bddfe5a1da0202
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+ {{ activeIteration ? activeIteration.title : inputValue }}
+
+
+
+ {{ iteration.text }}
+
+
+
+
+
+ {{ iteration.title }}
+
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue
new file mode 100644
index 0000000000000000000000000000000000000000..cfad79b9afaacf7bd0cc102ca0eabcfc1501a45f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+ {{ weight.text }}
+
+
+
+ {{ weight }}
+
+
+
+
diff --git a/ee/app/helpers/ee/issues_helper.rb b/ee/app/helpers/ee/issues_helper.rb
index 82ce527267db1ba16f6a9e10db5d7ad5b338d2d0..4ef4b4618fde46d725372fb1bdc33f22cc9714ec 100644
--- a/ee/app/helpers/ee/issues_helper.rb
+++ b/ee/app/helpers/ee/issues_helper.rb
@@ -71,11 +71,17 @@ def issue_header_actions_data(project, issuable, current_user)
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
diff --git a/ee/spec/helpers/ee/issues_helper_spec.rb b/ee/spec/helpers/ee/issues_helper_spec.rb
index 319de03fd262bf025d285cc726e8ae6829ce95d2..544c1c79c7094c0d6e5cb32caa1f91c9cadfec6f 100644
--- a/ee/spec/helpers/ee/issues_helper_spec.rb
+++ b/ee/spec/helpers/ee/issues_helper_spec.rb
@@ -124,23 +124,49 @@
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
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index f350f6ece5d215e077d02d44ba2d764495382da2..1a263fea5bcabec49fbace2f5fb0d6dace0bfbfc 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -32168,6 +32168,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 ""
diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js
index faeab0c244dd88137211d2833357248e5abb7861..f75c3d8bcb9c4dd76245db95d53064295aead229 100644
--- a/spec/frontend/issues_list/mock_data.js
+++ b/spec/frontend/issues_list/mock_data.js
@@ -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'],
};
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
index 46b7e49979e5178e32f01321b21e1c82de204e6d..c49a1ab68b1e0f3ea6dc242aa09e1f984b74805a 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
@@ -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' }],
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..ca5dc984ae0ad20802f91f9c3a5d126e44d21f3a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js
@@ -0,0 +1,78 @@
+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.',
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..9a72be636cd47aee116578b345825ebefe01430a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js
@@ -0,0 +1,37 @@
+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);
+ });
+});