Commit eaf3f11a authored by charlie ablett's avatar charlie ablett Committed by Igor Drozdov

Requirements filter by author username or search

- Add scopes to Requirement, plus tests
- Add filters to RequirementsFinder, plus tests
- Add filter args to GraphQL resolver, plus tests
parent 781b52ec
...@@ -8309,6 +8309,11 @@ type Project { ...@@ -8309,6 +8309,11 @@ type Project {
""" """
after: String after: String
"""
Filter requirements by author username
"""
authorUsername: [String!]
""" """
Returns the elements in the list that come before the specified cursor. Returns the elements in the list that come before the specified cursor.
""" """
...@@ -8405,6 +8410,11 @@ type Project { ...@@ -8405,6 +8410,11 @@ type Project {
Find a single requirement. Available only when feature flag `requirements_management` is enabled. Find a single requirement. Available only when feature flag `requirements_management` is enabled.
""" """
requirement( requirement(
"""
Filter requirements by author username
"""
authorUsername: [String!]
""" """
IID of the requirement, e.g., "1" IID of the requirement, e.g., "1"
""" """
...@@ -8415,6 +8425,11 @@ type Project { ...@@ -8415,6 +8425,11 @@ type Project {
""" """
iids: [ID!] iids: [ID!]
"""
Filter requirements by title search
"""
search: String
""" """
List requirements by sort order List requirements by sort order
""" """
...@@ -8465,6 +8480,11 @@ type Project { ...@@ -8465,6 +8480,11 @@ type Project {
""" """
last: Int last: Int
"""
Filter requirements by title search
"""
search: String
""" """
List requirements by sort order List requirements by sort order
""" """
...@@ -9609,7 +9629,7 @@ type Repository { ...@@ -9609,7 +9629,7 @@ type Repository {
} }
""" """
Represents a requirement. Represents a requirement
""" """
type Requirement { type Requirement {
""" """
......
...@@ -24398,6 +24398,34 @@ ...@@ -24398,6 +24398,34 @@
"ofType": null "ofType": null
}, },
"defaultValue": null "defaultValue": null
},
{
"name": "search",
"description": "Filter requirements by title search",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "authorUsername",
"description": "Filter requirements by author username",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
} }
], ],
"type": { "type": {
...@@ -24460,6 +24488,34 @@ ...@@ -24460,6 +24488,34 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "search",
"description": "Filter requirements by title search",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "authorUsername",
"description": "Filter requirements by author username",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
{ {
"name": "after", "name": "after",
"description": "Returns the elements in the list that come after the specified cursor.", "description": "Returns the elements in the list that come after the specified cursor.",
...@@ -28132,7 +28188,7 @@ ...@@ -28132,7 +28188,7 @@
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "Requirement", "name": "Requirement",
"description": "Represents a requirement.", "description": "Represents a requirement",
"fields": [ "fields": [
{ {
"name": "author", "name": "author",
...@@ -1356,7 +1356,7 @@ Autogenerated return type of RemoveProjectFromSecurityDashboard ...@@ -1356,7 +1356,7 @@ Autogenerated return type of RemoveProjectFromSecurityDashboard
## Requirement ## Requirement
Represents a requirement. Represents a requirement
| Name | Type | Description | | Name | Type | Description |
| --- | ---- | ---------- | | --- | ---- | ---------- |
......
...@@ -73,3 +73,23 @@ You can view the list of archived requirements in the **Archived** tab. ...@@ -73,3 +73,23 @@ You can view the list of archived requirements in the **Archived** tab.
To reopen an archived requirement, click **Reopen**. To reopen an archived requirement, click **Reopen**.
As soon as a requirement is reopened, it no longer appears in the **Archived** tab. As soon as a requirement is reopened, it no longer appears in the **Archived** tab.
## Search for a requirement from the requirements list page
> - Introduced in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.1.
You can search for a requirement from the list of requirements using filtered search bar (similar to
that of Issues and Merge Requests) based on following parameters:
- Title
- Author username
To search, go to the list of requirements and click the field **Search or filter results**.
It will display a dropdown menu, from which you can add an author. You can also enter plain
text to search by epic title or description. When done, press <kbd>Enter</kbd> on your
keyboard to filter the list.
You can also sort requirements list by:
- Created date
- Last updated
...@@ -9,6 +9,8 @@ module RequirementsManagement ...@@ -9,6 +9,8 @@ module RequirementsManagement
# iids: integer[] # iids: integer[]
# state: string[] # state: string[]
# sort: string # sort: string
# search: string
# author_username: string
def initialize(current_user, params = {}) def initialize(current_user, params = {})
@current_user = current_user @current_user = current_user
@params = params @params = params
...@@ -18,6 +20,8 @@ module RequirementsManagement ...@@ -18,6 +20,8 @@ module RequirementsManagement
items = init_collection items = init_collection
items = by_state(items) items = by_state(items)
items = by_iid(items) items = by_iid(items)
items = by_author(items)
items = by_search(items)
sort(items) sort(items)
end end
...@@ -44,6 +48,29 @@ module RequirementsManagement ...@@ -44,6 +48,29 @@ module RequirementsManagement
items.for_state(params[:state]) items.for_state(params[:state])
end end
def by_author(items)
username_param = params[:author_username]
return items unless username_param.present?
authors = get_authors(username_param)
return items.none unless authors.present? # author not found
items.with_author(authors)
end
def get_authors(username_param)
# Save a DB hit if the current_user is the only author, or there are none.
return current_user if [username_param].flatten == [current_user&.username]
User.by_username(username_param)
end
def by_search(items)
return items unless params[:search].present?
items.search(params[:search])
end
def project def project
strong_memoize(:project) do strong_memoize(:project) do
::Project.find_by_id(params[:project_id]) if params[:project_id].present? ::Project.find_by_id(params[:project_id]) if params[:project_id].present?
......
...@@ -19,6 +19,14 @@ module Resolvers ...@@ -19,6 +19,14 @@ module Resolvers
required: false, required: false,
description: 'Filter requirements by state' description: 'Filter requirements by state'
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Filter requirements by title search'
argument :author_username, [GraphQL::STRING_TYPE],
required: false,
description: 'Filter requirements by author username'
type Types::RequirementsManagement::RequirementType, null: true type Types::RequirementsManagement::RequirementType, null: true
def resolve(**args) def resolve(**args)
......
...@@ -4,7 +4,7 @@ module Types ...@@ -4,7 +4,7 @@ module Types
module RequirementsManagement module RequirementsManagement
class RequirementType < BaseObject class RequirementType < BaseObject
graphql_name 'Requirement' graphql_name 'Requirement'
description 'Represents a requirement.' description 'Represents a requirement'
authorize :read_requirement authorize :read_requirement
......
...@@ -6,6 +6,7 @@ module RequirementsManagement ...@@ -6,6 +6,7 @@ module RequirementsManagement
include StripAttribute include StripAttribute
include AtomicInternalId include AtomicInternalId
include Sortable include Sortable
include Gitlab::SQL::Pattern
# the expected name for this table is `requirements_management_requirements`, # the expected name for this table is `requirements_management_requirements`,
# but to avoid downtime and deployment issues `requirements` is still used # but to avoid downtime and deployment issues `requirements` is still used
...@@ -32,11 +33,25 @@ module RequirementsManagement ...@@ -32,11 +33,25 @@ module RequirementsManagement
scope :for_iid, -> (iid) { where(iid: iid) } scope :for_iid, -> (iid) { where(iid: iid) }
scope :for_state, -> (state) { where(state: state) } scope :for_state, -> (state) { where(state: state) }
scope :with_author, -> (user) { where(author: user) }
scope :counts_by_state, -> { group(:state).count } scope :counts_by_state, -> { group(:state).count }
def self.simple_sorts class << self
# Searches for records with a matching title.
#
# This method uses ILIKE on PostgreSQL
#
# query - The search query as a String
#
# Returns an ActiveRecord::Relation.
def search(query)
fuzzy_search(query, [:title])
end
def simple_sorts
super.except('name_asc', 'name_desc') super.except('name_asc', 'name_desc')
end end
end
# In the next iteration we will support also group-level requirements # In the next iteration we will support also group-level requirements
# so it's better to use resource_parent instead of project directly # so it's better to use resource_parent instead of project directly
......
---
title: Add requirements filtering on author username and search by title
merge_request: 31857
author:
type: added
...@@ -4,11 +4,12 @@ require 'spec_helper' ...@@ -4,11 +4,12 @@ require 'spec_helper'
describe RequirementsManagement::RequirementsFinder do describe RequirementsManagement::RequirementsFinder do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:project_user) { create(:user).tap { |u| project.add_developer(u) } } let_it_be(:project_user) { create(:user, username: 'projectusername').tap { |u| project.add_developer(u) } }
let_it_be(:requirement1) { create(:requirement, project: project, state: 'opened', updated_at: 3.days.ago) } let_it_be(:other_user) { create(:user, username: 'otheruser123') }
let_it_be(:requirement2) { create(:requirement, project: project, state: 'opened', updated_at: 1.day.ago) } let_it_be(:requirement1) { create(:requirement, project: project, state: 'opened', author: project_user, updated_at: 3.days.ago, title: 'make it better with serverless') }
let_it_be(:requirement3) { create(:requirement, project: project, state: 'archived', updated_at: 2.days.ago) } let_it_be(:requirement2) { create(:requirement, project: project, state: 'opened', author: project_user, updated_at: 1.day.ago, title: 'make it not crash') }
let_it_be(:requirement4) { create(:requirement, state: 'opened') } let_it_be(:requirement3) { create(:requirement, project: project, state: 'archived', author: other_user, updated_at: 2.days.ago, title: 'good with memory') }
let_it_be(:requirement4) { create(:requirement, state: 'opened', title: 'mystery requirement') }
subject { described_class.new(project_user, params).execute } subject { described_class.new(project_user, params).execute }
...@@ -59,6 +60,46 @@ describe RequirementsManagement::RequirementsFinder do ...@@ -59,6 +60,46 @@ describe RequirementsManagement::RequirementsFinder do
end end
end end
describe 'filter by author' do
using RSpec::Parameterized::TableSyntax
let(:params) { { project_id: project.id, author_username: author_username } }
where(:author_username, :filtered_requirements) do
'projectusername' | [:requirement1, :requirement2]
'nonexistent_user' | []
nil | [:requirement3, :requirement2, :requirement1]
%w[projectusername otheruser123] | [:requirement3, :requirement2, :requirement1]
%w[nonexistentuser nonsense] | []
end
with_them do
it 'returns the requirements filtered' do
expect(subject).to match_array(filtered_requirements.map { |name| public_send(name) })
end
end
end
describe 'filter by search' do
using RSpec::Parameterized::TableSyntax
let(:params) { { project_id: project.id, search: query } }
where(:query, :filtered_requirements) do
'nonsense' | []
'serverless' | [:requirement1]
'with' | [:requirement1, :requirement3]
nil | [:requirement3, :requirement2, :requirement1]
"" | [:requirement3, :requirement2, :requirement1]
end
with_them do
it 'returns the requirements filtered' do
expect(subject).to match_array(filtered_requirements.map { |name| public_send(name) })
end
end
end
describe 'ordering' do describe 'ordering' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
......
...@@ -6,12 +6,14 @@ describe Resolvers::RequirementsManagement::RequirementsResolver do ...@@ -6,12 +6,14 @@ describe Resolvers::RequirementsManagement::RequirementsResolver do
include GraphqlHelpers include GraphqlHelpers
let_it_be(:current_user) { create(:user) } let_it_be(:current_user) { create(:user) }
let_it_be(:other_user) { create(:user) }
let_it_be(:third_user) { create(:user) }
let_it_be(:project) { create(:project) }
context 'with a project' do context 'with a project' do
let_it_be(:project) { create(:project) } let_it_be(:requirement1) { create(:requirement, project: project, state: :opened, created_at: 5.hours.ago, title: 'it needs to do the thing', author: current_user) }
let_it_be(:requirement1) { create(:requirement, project: project, state: :opened, created_at: 5.hours.ago) } let_it_be(:requirement2) { create(:requirement, project: project, state: :archived, created_at: 3.hours.ago, title: 'it needs to not break', author: other_user) }
let_it_be(:requirement2) { create(:requirement, project: project, state: :archived, created_at: 3.hours.ago) } let_it_be(:requirement3) { create(:requirement, project: project, state: :archived, created_at: 4.hours.ago, title: 'do the kubernetes!', author: third_user) }
let_it_be(:requirement3) { create(:requirement, project: project, state: :archived, created_at: 4.hours.ago) }
before do before do
project.add_developer(current_user) project.add_developer(current_user)
...@@ -65,9 +67,105 @@ describe Resolvers::RequirementsManagement::RequirementsResolver do ...@@ -65,9 +67,105 @@ describe Resolvers::RequirementsManagement::RequirementsResolver do
expect(resolve_requirements).to be_empty expect(resolve_requirements).to be_empty
end end
end end
context 'with search' do
it 'filters requirements by title' do
requirements = resolve_requirements(search: 'kubernetes')
expect(requirements).to match_array([requirement3])
end
end
shared_examples 'returns unfiltered' do
it 'returns requirements without filtering by author' do
expect(subject).to match_array([requirement1, requirement2, requirement3])
end
end
shared_examples 'returns no items' do
it 'returns requirements without filtering by author' do
expect(subject).to be_empty
end
end
context 'filtering by author_username' do
subject do
resolve_requirements(params)
end
context 'single author exists' do
let(:params) do
{ author_username: other_user.username }
end
it 'filters requirements by author' do
expect(subject).to match_array([requirement2])
end
end
context 'single nonexistent author' do
let(:params) do
{ author_username: "nonsense" }
end
it_behaves_like 'returns no items'
end
context 'multiple nonexistent authors' do
let(:params) do
{ author_username: %w[undefined123 nonsense] }
end
it_behaves_like 'returns no items'
end
context 'single author is not supplied' do
let(:params) do
{}
end
it_behaves_like 'returns unfiltered'
end
context 'single author is nil' do
let(:params) do
{ author_username: nil }
end
it_behaves_like 'returns unfiltered'
end
context 'an empty array' do
let(:params) do
{ author_username: [] }
end
it_behaves_like 'returns unfiltered'
end
context 'multiple authors' do
let(:params) do
{ author_username: [other_user.username, current_user.username] }
end
it 'filters requirements by author' do
expect(subject).to match_array([requirement1, requirement2])
end
end
context 'multiple authors, one of whom does not exist' do
let(:params) do
{ author_username: [other_user.username, 'nonsense'] }
end
it 'filters requirements by author' do
expect(subject).to match_array([requirement2])
end
end
end end
def resolve_requirements(args = {}, context = { current_user: current_user }) def resolve_requirements(args = {}, context = { current_user: current_user })
resolve(described_class, obj: project, args: args, ctx: context) resolve(described_class, obj: project, args: args, ctx: context)
end end
end
end end
...@@ -33,5 +33,48 @@ describe RequirementsManagement::Requirement do ...@@ -33,5 +33,48 @@ describe RequirementsManagement::Requirement do
it { is_expected.to contain_exactly(['archived', 1], ['opened', 1]) } it { is_expected.to contain_exactly(['archived', 1], ['opened', 1]) }
end end
describe '.with_author' do
let_it_be(:other_user) { create(:user) }
let_it_be(:my_requirement) { create(:requirement, project: project, author: user) }
let_it_be(:other_requirement) { create(:requirement, project: project, author: other_user) }
context 'with one author' do
subject { described_class.with_author(user) }
it { is_expected.to contain_exactly(my_requirement) }
end
context 'with multiple authors' do
subject { described_class.with_author([user, other_user]) }
it { is_expected.to contain_exactly(my_requirement, other_requirement) }
end
end
describe '.search' do
let_it_be(:requirement_one) { create(:requirement, project: project, title: "it needs to do the thing") }
let_it_be(:requirement_two) { create(:requirement, project: project, title: "it needs to not break") }
subject { described_class.search(query) }
context 'with a query that covers both' do
let(:query) { 'it needs to' }
it { is_expected.to contain_exactly(requirement_one, requirement_two) }
end
context 'with a query that covers neither' do
let(:query) { 'break often' }
it { is_expected.to be_empty }
end
context 'with a query that covers one' do
let(:query) { 'do the thing' }
it { is_expected.to contain_exactly(requirement_one) }
end
end
end end
end end
...@@ -63,6 +63,77 @@ describe 'getting a requirement list for a project' do ...@@ -63,6 +63,77 @@ describe 'getting a requirement list for a project' do
end end
end end
describe 'filtering' do
let_it_be(:filter_project) { create(:project, :public) }
let_it_be(:other_project) { create(:project, :public) }
let_it_be(:other_user) { create(:user, username: 'number8wire') }
let_it_be(:requirement1) { create(:requirement, project: filter_project, author: current_user, title: 'solve the halting problem') }
let_it_be(:requirement2) { create(:requirement, project: filter_project, author: other_user, title: 'something about kubernetes') }
before do
post_graphql(query, current_user: current_user)
end
let(:requirements_data) { graphql_data['project']['requirements']['nodes'] }
let(:params) { "" }
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => filter_project.full_path },
<<~REQUIREMENTS
requirements#{params} {
nodes {
id
}
}
REQUIREMENTS
)
end
it_behaves_like 'a working graphql query'
def match_single_result(requirement)
expect(requirements_data[0]['id']).to eq requirement.to_global_id.to_s
end
context 'when given single author param' do
let(:params) { '(authorUsername: "number8wire")' }
it 'returns filtered requirements' do
expect(graphql_errors).to be_nil
match_single_result(requirement2)
end
end
context 'when given multiple author param' do
let(:params) { '(authorUsername: ["number8wire", "someotheruser"])' }
it 'returns filtered requirements' do
expect(graphql_errors).to be_nil
match_single_result(requirement2)
end
end
context 'when given search param' do
let(:params) { '(search: "halting")' }
it 'returns filtered requirements' do
expect(graphql_errors).to be_nil
match_single_result(requirement1)
end
end
context 'when given author and search params' do
let(:params) { '(search: "kubernetes", authorUsername: "number8wire")' }
it 'returns filtered requirements' do
expect(graphql_errors).to be_nil
match_single_result(requirement2)
end
end
end
describe 'sorting and pagination' do describe 'sorting and pagination' do
let(:start_cursor) { graphql_data['project']['requirements']['pageInfo']['startCursor'] } let(:start_cursor) { graphql_data['project']['requirements']['pageInfo']['startCursor'] }
let(:end_cursor) { graphql_data['project']['requirements']['pageInfo']['endCursor'] } let(:end_cursor) { graphql_data['project']['requirements']['pageInfo']['endCursor'] }
......
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