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 {
runnerTagsEndpoint,
labelsEndpoint,
milestonesEndpoint,
releasesEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
......@@ -21,6 +22,7 @@ export default class AvailableDropdownMappings {
this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint;
this.releasesEndpoint = releasesEndpoint;
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
......@@ -70,6 +72,19 @@ export default class AvailableDropdownMappings {
},
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: {
reference: null,
gl: DropdownNonUser,
......@@ -130,6 +145,10 @@ export default class AvailableDropdownMappings {
return `${this.milestonesEndpoint}.json`;
}
getReleasesEndpoint() {
return `${this.releasesEndpoint}.json`;
}
getLabelsEndpoint() {
let endpoint = `${this.labelsEndpoint}.json?`;
......
......@@ -11,6 +11,7 @@ export default class FilteredSearchDropdownManager {
runnerTagsEndpoint = '',
labelsEndpoint = '',
milestonesEndpoint = '',
releasesEndpoint = '',
tokenizer,
page,
isGroup,
......@@ -18,10 +19,13 @@ export default class FilteredSearchDropdownManager {
isGroupDecendent,
filteredSearchTokenKeys,
}) {
const removeTrailingSlash = url => url.replace(/\/$/, '');
this.container = FilteredSearchContainer.container;
this.runnerTagsEndpoint = runnerTagsEndpoint.replace(/\/$/, '');
this.labelsEndpoint = labelsEndpoint.replace(/\/$/, '');
this.milestonesEndpoint = milestonesEndpoint.replace(/\/$/, '');
this.runnerTagsEndpoint = removeTrailingSlash(runnerTagsEndpoint);
this.labelsEndpoint = removeTrailingSlash(labelsEndpoint);
this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint);
this.releasesEndpoint = removeTrailingSlash(releasesEndpoint);
this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
......@@ -54,6 +58,7 @@ export default class FilteredSearchDropdownManager {
this.runnerTagsEndpoint,
this.labelsEndpoint,
this.milestonesEndpoint,
this.releasesEndpoint,
this.groupsOnly,
this.includeAncestorGroups,
this.includeDescendantGroups,
......
......@@ -89,6 +89,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '',
labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '',
milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '',
releasesEndpoint: this.filteredSearchInput.getAttribute('data-releases-endpoint') || '',
tokenizer: this.tokenizer,
page: this.page,
isGroup: this.isGroup,
......
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import { __ } from '~/locale';
export const tokenKeys = [
export const tokenKeys = [];
tokenKeys.push(
{
key: 'author',
type: 'string',
......@@ -26,15 +28,27 @@ export const tokenKeys = [
icon: 'clock',
tag: '%milestone',
},
{
);
if (gon && gon.features && gon.features.releaseSearchFilter) {
tokenKeys.push({
key: 'release',
type: 'string',
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) {
// Appending tokenkeys only logged-in
......@@ -88,6 +102,16 @@ export const conditions = [
tokenKey: 'milestone',
value: __('Started'),
},
{
url: 'release_tag=None',
tokenKey: 'release',
value: __('None'),
},
{
url: 'release_tag=Any',
tokenKey: 'release',
value: __('Any'),
},
{
url: 'label_name[]=None',
tokenKey: 'label',
......
......@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:release_search_filter, project)
end
respond_to :html
......
......@@ -2,12 +2,24 @@
class Projects::ReleasesController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
before_action :require_non_empty_project, except: [:index]
before_action :authorize_read_release!
before_action do
push_frontend_feature_flag(:release_edit_page, project)
end
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
......@@ -236,6 +236,7 @@ module SearchHelper
opts[:data]['project-id'] = @project.id
opts[:data]['labels-endpoint'] = project_labels_path(@project)
opts[:data]['milestones-endpoint'] = project_milestones_path(@project)
opts[:data]['releases-endpoint'] = project_releases_path(@project)
elsif @group.present?
opts[:data]['group-id'] = @group.id
opts[:data]['labels-endpoint'] = group_labels_path(@group)
......
......@@ -91,6 +91,19 @@
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value{ type: 'button' }
{{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
%ul{ data: { dropdown: true } }
%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 {
runnerTagsEndpoint,
labelsEndpoint,
milestonesEndpoint,
releasesEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
......@@ -17,6 +18,7 @@ export default class AvailableDropdownMappings {
this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint;
this.releasesEndpoint = releasesEndpoint;
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
......@@ -26,6 +28,7 @@ export default class AvailableDropdownMappings {
runnerTagsEndpoint,
labelsEndpoint,
milestonesEndpoint,
releasesEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
......
......@@ -20488,6 +20488,9 @@ msgstr ""
msgid "syntax is incorrect"
msgstr ""
msgid "tag name"
msgstr ""
msgid "this document"
msgstr ""
......
......@@ -4,9 +4,12 @@ require 'spec_helper'
describe Projects::ReleasesController do
let!(:project) { create(:project, :repository, :public) }
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
get_index
......@@ -14,17 +17,14 @@ describe Projects::ReleasesController do
end
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
it 'renders a 200 for a logged in developer' do
project.add_developer(user)
sign_in(user)
get_index
......@@ -32,8 +32,6 @@ describe Projects::ReleasesController do
end
it 'renders a 404 when logged in but not in the project' do
sign_in(user)
get_index
expect(response.status).to eq(404)
......@@ -41,9 +39,55 @@ describe Projects::ReleasesController do
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
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
......@@ -68,7 +68,7 @@ describe 'Dropdown hint', :js do
it 'filters with text' do
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
......@@ -104,6 +104,15 @@ describe 'Dropdown hint', :js do
expect_filtered_search_input_empty
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
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
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]['milestones-endpoint']).to eq(project_milestones_path(@project))
expect(search_filter_input_options('')[:data]['releases-endpoint']).to eq(project_releases_path(@project))
end
it 'includes autocomplete=off flag' do
......
......@@ -114,6 +114,10 @@ module FilteredSearchHelpers
create_token('Milestone', milestone_name, symbol)
end
def release_token(release_tag = nil)
create_token('Release', release_tag)
end
def label_token(label_name = nil, has_symbol = true)
symbol = has_symbol ? '~' : nil
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