Commit e4cceff2 authored by Nathan Friend's avatar Nathan Friend

Add "release" filter to issue search page

his commit adds a new filter - "release" - to the issue search page.
This filter takes a tag name (i.e. v1.2) as a parameter and appends a
URL parameter to the search query like "release_tag=v1.2".
parent a8c1eb4a
...@@ -13,6 +13,7 @@ export default class AvailableDropdownMappings { ...@@ -13,6 +13,7 @@ export default class AvailableDropdownMappings {
runnerTagsEndpoint, runnerTagsEndpoint,
labelsEndpoint, labelsEndpoint,
milestonesEndpoint, milestonesEndpoint,
releasesEndpoint,
groupsOnly, groupsOnly,
includeAncestorGroups, includeAncestorGroups,
includeDescendantGroups, includeDescendantGroups,
...@@ -21,6 +22,7 @@ export default class AvailableDropdownMappings { ...@@ -21,6 +22,7 @@ export default class AvailableDropdownMappings {
this.runnerTagsEndpoint = runnerTagsEndpoint; this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint; this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint; this.milestonesEndpoint = milestonesEndpoint;
this.releasesEndpoint = releasesEndpoint;
this.groupsOnly = groupsOnly; this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups; this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups; this.includeDescendantGroups = includeDescendantGroups;
...@@ -70,6 +72,19 @@ export default class AvailableDropdownMappings { ...@@ -70,6 +72,19 @@ export default class AvailableDropdownMappings {
}, },
element: this.container.querySelector('#js-dropdown-milestone'), element: this.container.querySelector('#js-dropdown-milestone'),
}, },
release: {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getReleasesEndpoint(),
symbol: '',
// The DropdownNonUser class is hardcoded to look for and display a
// "title" property, so we need to add this property to each release object
preprocessing: releases => releases.map(r => ({ ...r, title: r.tag })),
},
element: this.container.querySelector('#js-dropdown-release'),
},
label: { label: {
reference: null, reference: null,
gl: DropdownNonUser, gl: DropdownNonUser,
...@@ -130,6 +145,10 @@ export default class AvailableDropdownMappings { ...@@ -130,6 +145,10 @@ export default class AvailableDropdownMappings {
return `${this.milestonesEndpoint}.json`; return `${this.milestonesEndpoint}.json`;
} }
getReleasesEndpoint() {
return `${this.releasesEndpoint}.json`;
}
getLabelsEndpoint() { getLabelsEndpoint() {
let endpoint = `${this.labelsEndpoint}.json?`; let endpoint = `${this.labelsEndpoint}.json?`;
......
...@@ -11,6 +11,7 @@ export default class FilteredSearchDropdownManager { ...@@ -11,6 +11,7 @@ export default class FilteredSearchDropdownManager {
runnerTagsEndpoint = '', runnerTagsEndpoint = '',
labelsEndpoint = '', labelsEndpoint = '',
milestonesEndpoint = '', milestonesEndpoint = '',
releasesEndpoint = '',
tokenizer, tokenizer,
page, page,
isGroup, isGroup,
...@@ -18,10 +19,13 @@ export default class FilteredSearchDropdownManager { ...@@ -18,10 +19,13 @@ export default class FilteredSearchDropdownManager {
isGroupDecendent, isGroupDecendent,
filteredSearchTokenKeys, filteredSearchTokenKeys,
}) { }) {
const removeTrailingSlash = url => url.replace(/\/$/, '');
this.container = FilteredSearchContainer.container; this.container = FilteredSearchContainer.container;
this.runnerTagsEndpoint = runnerTagsEndpoint.replace(/\/$/, ''); this.runnerTagsEndpoint = removeTrailingSlash(runnerTagsEndpoint);
this.labelsEndpoint = labelsEndpoint.replace(/\/$/, ''); this.labelsEndpoint = removeTrailingSlash(labelsEndpoint);
this.milestonesEndpoint = milestonesEndpoint.replace(/\/$/, ''); this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint);
this.releasesEndpoint = removeTrailingSlash(releasesEndpoint);
this.tokenizer = tokenizer; this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys; this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInput = this.container.querySelector('.filtered-search');
...@@ -54,6 +58,7 @@ export default class FilteredSearchDropdownManager { ...@@ -54,6 +58,7 @@ export default class FilteredSearchDropdownManager {
this.runnerTagsEndpoint, this.runnerTagsEndpoint,
this.labelsEndpoint, this.labelsEndpoint,
this.milestonesEndpoint, this.milestonesEndpoint,
this.releasesEndpoint,
this.groupsOnly, this.groupsOnly,
this.includeAncestorGroups, this.includeAncestorGroups,
this.includeDescendantGroups, this.includeDescendantGroups,
......
...@@ -89,6 +89,7 @@ export default class FilteredSearchManager { ...@@ -89,6 +89,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '', this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '',
labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '', labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '',
milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '', milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '',
releasesEndpoint: this.filteredSearchInput.getAttribute('data-releases-endpoint') || '',
tokenizer: this.tokenizer, tokenizer: this.tokenizer,
page: this.page, page: this.page,
isGroup: this.isGroup, isGroup: this.isGroup,
......
import FilteredSearchTokenKeys from './filtered_search_token_keys'; import FilteredSearchTokenKeys from './filtered_search_token_keys';
import { __ } from '~/locale'; import { __ } from '~/locale';
export const tokenKeys = [ export const tokenKeys = [];
tokenKeys.push(
{ {
key: 'author', key: 'author',
type: 'string', type: 'string',
...@@ -26,15 +28,27 @@ export const tokenKeys = [ ...@@ -26,15 +28,27 @@ export const tokenKeys = [
icon: 'clock', icon: 'clock',
tag: '%milestone', tag: '%milestone',
}, },
{ );
key: 'label',
type: 'array', if (gon && gon.features && gon.features.releaseSearchFilter) {
param: 'name[]', tokenKeys.push({
symbol: '~', key: 'release',
icon: 'labels', type: 'string',
tag: '~label', param: 'tag',
}, symbol: '',
]; icon: 'rocket',
tag: __('tag name'),
});
}
tokenKeys.push({
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
icon: 'labels',
tag: '~label',
});
if (gon.current_user_id) { if (gon.current_user_id) {
// Appending tokenkeys only logged-in // Appending tokenkeys only logged-in
...@@ -88,6 +102,16 @@ export const conditions = [ ...@@ -88,6 +102,16 @@ export const conditions = [
tokenKey: 'milestone', tokenKey: 'milestone',
value: __('Started'), value: __('Started'),
}, },
{
url: 'release_tag=None',
tokenKey: 'release',
value: __('None'),
},
{
url: 'release_tag=Any',
tokenKey: 'release',
value: __('Any'),
},
{ {
url: 'label_name[]=None', url: 'label_name[]=None',
tokenKey: 'label', tokenKey: 'label',
......
...@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group) push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:release_search_filter, project)
end end
respond_to :html respond_to :html
......
...@@ -2,12 +2,24 @@ ...@@ -2,12 +2,24 @@
class Projects::ReleasesController < Projects::ApplicationController class Projects::ReleasesController < Projects::ApplicationController
# Authorize # Authorize
before_action :require_non_empty_project before_action :require_non_empty_project, except: [:index]
before_action :authorize_read_release! before_action :authorize_read_release!
before_action do before_action do
push_frontend_feature_flag(:release_edit_page, project) push_frontend_feature_flag(:release_edit_page, project)
end end
def index def index
respond_to do |format|
format.html do
require_non_empty_project
end
format.json { render json: releases }
end
end
protected
def releases
ReleasesFinder.new(@project, current_user).execute
end end
end end
...@@ -236,6 +236,7 @@ module SearchHelper ...@@ -236,6 +236,7 @@ module SearchHelper
opts[:data]['project-id'] = @project.id opts[:data]['project-id'] = @project.id
opts[:data]['labels-endpoint'] = project_labels_path(@project) opts[:data]['labels-endpoint'] = project_labels_path(@project)
opts[:data]['milestones-endpoint'] = project_milestones_path(@project) opts[:data]['milestones-endpoint'] = project_milestones_path(@project)
opts[:data]['releases-endpoint'] = project_releases_path(@project)
elsif @group.present? elsif @group.present?
opts[:data]['group-id'] = @group.id opts[:data]['group-id'] = @group.id
opts[:data]['labels-endpoint'] = group_labels_path(@group) opts[:data]['labels-endpoint'] = group_labels_path(@group)
......
...@@ -91,6 +91,19 @@ ...@@ -91,6 +91,19 @@
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link.js-data-value{ type: 'button' } %button.btn.btn-link.js-data-value{ type: 'button' }
{{title}} {{title}}
#js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
%button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
%button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } } %li.filter-dropdown-item{ data: { value: 'None' } }
......
---
title: Add "release" filter to issue search page
merge_request: 18761
author:
type: added
...@@ -9,6 +9,7 @@ export default class AvailableDropdownMappings { ...@@ -9,6 +9,7 @@ export default class AvailableDropdownMappings {
runnerTagsEndpoint, runnerTagsEndpoint,
labelsEndpoint, labelsEndpoint,
milestonesEndpoint, milestonesEndpoint,
releasesEndpoint,
groupsOnly, groupsOnly,
includeAncestorGroups, includeAncestorGroups,
includeDescendantGroups, includeDescendantGroups,
...@@ -17,6 +18,7 @@ export default class AvailableDropdownMappings { ...@@ -17,6 +18,7 @@ export default class AvailableDropdownMappings {
this.runnerTagsEndpoint = runnerTagsEndpoint; this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint; this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint; this.milestonesEndpoint = milestonesEndpoint;
this.releasesEndpoint = releasesEndpoint;
this.groupsOnly = groupsOnly; this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups; this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups; this.includeDescendantGroups = includeDescendantGroups;
...@@ -26,6 +28,7 @@ export default class AvailableDropdownMappings { ...@@ -26,6 +28,7 @@ export default class AvailableDropdownMappings {
runnerTagsEndpoint, runnerTagsEndpoint,
labelsEndpoint, labelsEndpoint,
milestonesEndpoint, milestonesEndpoint,
releasesEndpoint,
groupsOnly, groupsOnly,
includeAncestorGroups, includeAncestorGroups,
includeDescendantGroups, includeDescendantGroups,
......
...@@ -20488,6 +20488,9 @@ msgstr "" ...@@ -20488,6 +20488,9 @@ msgstr ""
msgid "syntax is incorrect" msgid "syntax is incorrect"
msgstr "" msgstr ""
msgid "tag name"
msgstr ""
msgid "this document" msgid "this document"
msgstr "" msgstr ""
......
...@@ -3,10 +3,13 @@ ...@@ -3,10 +3,13 @@
require 'spec_helper' require 'spec_helper'
describe Projects::ReleasesController do describe Projects::ReleasesController do
let!(:project) { create(:project, :repository, :public) } let!(:project) { create(:project, :repository, :public) }
let!(:user) { create(:user) } let!(:private_project) { create(:project, :repository, :private) }
let!(:user) { create(:user) }
let!(:release_1) { create(:release, project: project, released_at: Time.zone.parse('2018-10-18')) }
let!(:release_2) { create(:release, project: project, released_at: Time.zone.parse('2019-10-19')) }
describe 'GET #index' do shared_examples 'common access controls' do
it 'renders a 200' do it 'renders a 200' do
get_index get_index
...@@ -14,17 +17,14 @@ describe Projects::ReleasesController do ...@@ -14,17 +17,14 @@ describe Projects::ReleasesController do
end end
context 'when the project is private' do context 'when the project is private' do
let!(:project) { create(:project, :repository, :private) } let(:project) { private_project }
it 'renders a 302' do
get_index
expect(response.status).to eq(302) before do
sign_in(user)
end end
it 'renders a 200 for a logged in developer' do it 'renders a 200 for a logged in developer' do
project.add_developer(user) project.add_developer(user)
sign_in(user)
get_index get_index
...@@ -32,8 +32,6 @@ describe Projects::ReleasesController do ...@@ -32,8 +32,6 @@ describe Projects::ReleasesController do
end end
it 'renders a 404 when logged in but not in the project' do it 'renders a 404 when logged in but not in the project' do
sign_in(user)
get_index get_index
expect(response.status).to eq(404) expect(response.status).to eq(404)
...@@ -41,9 +39,55 @@ describe Projects::ReleasesController do ...@@ -41,9 +39,55 @@ describe Projects::ReleasesController do
end end
end end
describe 'GET #index' do
before do
get_index
end
context 'as html' do
let(:format) { :html }
it 'returns a text/html content_type' do
expect(response.content_type).to eq 'text/html'
end
it_behaves_like 'common access controls'
context 'when the project is private and the user is not logged in' do
let(:project) { private_project }
it 'renders a 302' do
expect(response.status).to eq(302)
end
end
end
context 'as json' do
let(:format) { :json }
it 'returns an application/json content_type' do
expect(response.content_type).to eq 'application/json'
end
it "returns the project's releases as JSON, ordered by released_at" do
expect(response.body).to eq([release_2, release_1].to_json)
end
it_behaves_like 'common access controls'
context 'when the project is private and the user is not logged in' do
let(:project) { private_project }
it 'renders a 401' do
expect(response.status).to eq(401)
end
end
end
end
private private
def get_index def get_index
get :index, params: { namespace_id: project.namespace, project_id: project } get :index, params: { namespace_id: project.namespace, project_id: project, format: format }
end end
end end
...@@ -68,7 +68,7 @@ describe 'Dropdown hint', :js do ...@@ -68,7 +68,7 @@ describe 'Dropdown hint', :js do
it 'filters with text' do it 'filters with text' do
filtered_search.set('a') filtered_search.set('a')
expect(find(js_dropdown_hint)).to have_selector('.filter-dropdown .filter-dropdown-item', count: 5) expect(find(js_dropdown_hint)).to have_selector('.filter-dropdown .filter-dropdown-item', count: 6)
end end
end end
...@@ -104,6 +104,15 @@ describe 'Dropdown hint', :js do ...@@ -104,6 +104,15 @@ describe 'Dropdown hint', :js do
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
it 'opens the release dropdown when you click on release' do
click_hint('release')
expect(page).to have_css(js_dropdown_hint, visible: false)
expect(page).to have_css('#js-dropdown-release', visible: true)
expect_tokens([{ name: 'Release' }])
expect_filtered_search_input_empty
end
it 'opens the label dropdown when you click on label' do it 'opens the label dropdown when you click on label' do
click_hint('label') click_hint('label')
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Dropdown release', :js do
include FilteredSearchHelpers
let!(:project) { create(:project, :repository) }
let!(:user) { create(:user) }
let!(:release) { create(:release, tag: 'v1.0', project: project) }
let!(:crazy_release) { create(:release, tag: '☺!/"#%&\'{}+,-.<>;=@]_`{|}🚀', project: project) }
def filtered_search
find('.filtered-search')
end
def filter_dropdown
find('#js-dropdown-release .filter-dropdown')
end
before do
project.add_maintainer(user)
sign_in(user)
create(:issue, project: project)
visit project_issues_path(project)
end
describe 'behavior' do
before do
filtered_search.set('release:')
end
def expect_results(count)
expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: count)
end
it 'loads all the releases when opened' do
expect_results(2)
end
it 'filters by tag name' do
filtered_search.send_keys("☺")
expect_results(1)
end
it 'fills in the release name when the autocomplete hint is clicked' do
find('#js-dropdown-release .filter-dropdown-item', text: crazy_release.tag).click
expect(page).to have_css('#js-dropdown-release', visible: false)
expect_tokens([release_token(crazy_release.tag)])
expect_filtered_search_input_empty
end
end
end
...@@ -167,6 +167,7 @@ describe SearchHelper do ...@@ -167,6 +167,7 @@ describe SearchHelper do
expect(search_filter_input_options('')[:data]['runner-tags-endpoint']).to eq(tag_list_admin_runners_path) expect(search_filter_input_options('')[:data]['runner-tags-endpoint']).to eq(tag_list_admin_runners_path)
expect(search_filter_input_options('')[:data]['labels-endpoint']).to eq(project_labels_path(@project)) expect(search_filter_input_options('')[:data]['labels-endpoint']).to eq(project_labels_path(@project))
expect(search_filter_input_options('')[:data]['milestones-endpoint']).to eq(project_milestones_path(@project)) expect(search_filter_input_options('')[:data]['milestones-endpoint']).to eq(project_milestones_path(@project))
expect(search_filter_input_options('')[:data]['releases-endpoint']).to eq(project_releases_path(@project))
end end
it 'includes autocomplete=off flag' do it 'includes autocomplete=off flag' do
......
...@@ -114,6 +114,10 @@ module FilteredSearchHelpers ...@@ -114,6 +114,10 @@ module FilteredSearchHelpers
create_token('Milestone', milestone_name, symbol) create_token('Milestone', milestone_name, symbol)
end end
def release_token(release_tag = nil)
create_token('Release', release_tag)
end
def label_token(label_name = nil, has_symbol = true) def label_token(label_name = nil, has_symbol = true)
symbol = has_symbol ? '~' : nil symbol = has_symbol ? '~' : nil
create_token('Label', label_name, symbol) create_token('Label', label_name, symbol)
......
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