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 @@ + + + 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 @@ + + + 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); + }); +});