Commit 7c4ca6fa authored by Mario Celi's avatar Mario Celi

Add workItemTypes field to GraphqQL GroupType

Resolver at the group level to return all existing
default work item types. Eventually each group will have
different work item types.
Field behind the :work_items feature flag
Renamed WorkItem::Type => WorkItems::Type as the global ID
would break the API if changed later when the WorkItem model
is introduced.
parent 6fa714ee
......@@ -27,7 +27,7 @@
# updated_after: datetime
# updated_before: datetime
# confidential: boolean
# issue_types: array of strings (one of WorkItem::Type.base_types)
# issue_types: array of strings (one of WorkItems::Type.base_types)
#
class IssuesFinder < IssuableFinder
CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER
......@@ -124,13 +124,13 @@ class IssuesFinder < IssuableFinder
def by_issue_types(items)
issue_type_params = Array(params[:issue_types]).map(&:to_s)
return items if issue_type_params.blank?
return Issue.none unless (WorkItem::Type.base_types.keys & issue_type_params).sort == issue_type_params.sort
return Issue.none unless (WorkItems::Type.base_types.keys & issue_type_params).sort == issue_type_params.sort
items.with_issue_type(params[:issue_types])
end
def by_negated_issue_types(items)
issue_type_params = Array(not_params[:issue_types]).map(&:to_s) & WorkItem::Type.base_types.keys
issue_type_params = Array(not_params[:issue_types]).map(&:to_s) & WorkItems::Type.base_types.keys
return items if issue_type_params.blank?
items.without_issue_type(issue_type_params)
......
# frozen_string_literal: true
module Resolvers
module WorkItems
class TypesResolver < BaseResolver
type Types::WorkItems::TypeType.connection_type, null: true
def resolve
# This will require a finder in the future when groups get their work item types
# All groups use the default types for now
::WorkItems::Type.default.order_by_name_asc
end
end
end
end
......@@ -210,6 +210,11 @@ module Types
null: true,
description: "Find contacts of this group."
field :work_item_types, Types::WorkItems::TypeType.connection_type,
resolver: Resolvers::WorkItems::TypesResolver,
description: 'Work item types available to the group.',
feature_flag: :work_items
def avatar_url
object.avatar_url(only_path: false)
end
......
......@@ -5,7 +5,7 @@ module Types
graphql_name 'IssueType'
description 'Issue type'
::WorkItem::Type.allowed_types_for_issues.each do |issue_type|
::WorkItems::Type.allowed_types_for_issues.each do |issue_type|
value issue_type.upcase, value: issue_type, description: "#{issue_type.titleize} issue type"
end
end
......
# frozen_string_literal: true
module Types
module WorkItems
class TypeType < BaseObject
graphql_name 'WorkItemType'
authorize :read_work_item_type
field :icon_name, GraphQL::Types::String, null: true,
description: 'Icon name of the work item type.'
field :id, Types::GlobalIDType[::WorkItems::Type], null: false,
description: 'Global ID of the work item type.'
field :name, GraphQL::Types::String, null: false,
description: 'Name of the work item type.'
end
end
end
......@@ -51,7 +51,7 @@ module IssuesHelper
end
def work_item_type_icon(issue_type)
if WorkItem::Type.base_types.include?(issue_type)
if WorkItems::Type.base_types.include?(issue_type)
"issue-type-#{issue_type.to_s.dasherize}"
else
'issue-type-issue'
......
......@@ -48,7 +48,7 @@ class Issue < ApplicationRecord
belongs_to :duplicated_to, class_name: 'Issue'
belongs_to :closed_by, class_name: 'User'
belongs_to :iteration, foreign_key: 'sprint_id'
belongs_to :work_item_type, class_name: 'WorkItem::Type', inverse_of: :work_items
belongs_to :work_item_type, class_name: 'WorkItems::Type', inverse_of: :work_items
belongs_to :moved_to, class_name: 'Issue'
has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id
......@@ -94,7 +94,7 @@ class Issue < ApplicationRecord
validates :project, presence: true
validates :issue_type, presence: true
enum issue_type: WorkItem::Type.base_types
enum issue_type: WorkItems::Type.base_types
alias_method :issuing_parent, :project
......
# frozen_string_literal: true
# Note: initial thinking behind `icon_name` is for it to do triple duty:
# 1. one of our svg icon names, such as `external-link` or a new one `bug`
# 2. if it's an absolute url, then url to a user uploaded icon/image
# 3. an emoji, with the format of `:smile:`
class WorkItem::Type < ApplicationRecord
self.table_name = 'work_item_types'
include CacheMarkdownField
# Base types need to exist on the DB on app startup
# This constant is used by the DB seeder
BASE_TYPES = {
issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 },
incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 },
test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only
requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only
task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 }
}.freeze
cache_markdown_field :description, pipeline: :single_line
enum base_type: BASE_TYPES.transform_values { |value| value[:enum_value] }
belongs_to :namespace, optional: true
has_many :work_items, class_name: 'Issue', foreign_key: :work_item_type_id, inverse_of: :work_item_type
before_validation :strip_whitespace
# TODO: review validation rules
# https://gitlab.com/gitlab-org/gitlab/-/issues/336919
validates :name, presence: true
validates :name, uniqueness: { case_sensitive: false, scope: [:namespace_id] }
validates :name, length: { maximum: 255 }
validates :icon_name, length: { maximum: 255 }
def self.default_by_type(type)
find_by(namespace_id: nil, base_type: type)
end
def self.default_issue_type
default_by_type(:issue)
end
def self.allowed_types_for_issues
base_types.keys.excluding('task')
end
private
def strip_whitespace
name&.strip!
end
end
# frozen_string_literal: true
# Note: initial thinking behind `icon_name` is for it to do triple duty:
# 1. one of our svg icon names, such as `external-link` or a new one `bug`
# 2. if it's an absolute url, then url to a user uploaded icon/image
# 3. an emoji, with the format of `:smile:`
module WorkItems
class Type < ApplicationRecord
self.table_name = 'work_item_types'
include CacheMarkdownField
# Base types need to exist on the DB on app startup
# This constant is used by the DB seeder
BASE_TYPES = {
issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 },
incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 },
test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only
requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only
task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 }
}.freeze
cache_markdown_field :description, pipeline: :single_line
enum base_type: BASE_TYPES.transform_values { |value| value[:enum_value] }
belongs_to :namespace, optional: true
has_many :work_items, class_name: 'Issue', foreign_key: :work_item_type_id, inverse_of: :work_item_type
before_validation :strip_whitespace
# TODO: review validation rules
# https://gitlab.com/gitlab-org/gitlab/-/issues/336919
validates :name, presence: true
validates :name, uniqueness: { case_sensitive: false, scope: [:namespace_id] }
validates :name, length: { maximum: 255 }
validates :icon_name, length: { maximum: 255 }
scope :default, -> { where(namespace: nil) }
scope :order_by_name_asc, -> { order('LOWER(name)') }
def self.default_by_type(type)
find_by(namespace_id: nil, base_type: type)
end
def self.default_issue_type
default_by_type(:issue)
end
def self.allowed_types_for_issues
base_types.keys.excluding('task')
end
def default?
namespace.blank?
end
private
def strip_whitespace
name&.strip!
end
end
end
# frozen_string_literal: true
module WorkItems
class TypePolicy < BasePolicy
condition(:is_default_type) { @subject.default? }
rule { is_default_type }.enable :read_work_item_type
end
end
......@@ -5,7 +5,7 @@ module Issues
# @param object [Issue, Project]
# @param issue_type [String, Symbol]
def create_issue_type_allowed?(object, issue_type)
WorkItem::Type.base_types.key?(issue_type.to_s) &&
WorkItems::Type.base_types.key?(issue_type.to_s) &&
can?(current_user, :"create_#{issue_type}", object)
end
end
......
......@@ -36,8 +36,8 @@ module Issues
private
def find_work_item_type_id(issue_type)
work_item_type = WorkItem::Type.default_by_type(issue_type)
work_item_type ||= WorkItem::Type.default_issue_type
work_item_type = WorkItems::Type.default_by_type(issue_type)
work_item_type ||= WorkItems::Type.default_issue_type
work_item_type.id
end
......
......@@ -8115,6 +8115,29 @@ The edge type for [`VulnerabilityScanner`](#vulnerabilityscanner).
| <a id="vulnerabilityscanneredgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="vulnerabilityscanneredgenode"></a>`node` | [`VulnerabilityScanner`](#vulnerabilityscanner) | The item at the end of the edge. |
#### `WorkItemTypeConnection`
The connection type for [`WorkItemType`](#workitemtype).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemtypeconnectionedges"></a>`edges` | [`[WorkItemTypeEdge]`](#workitemtypeedge) | A list of edges. |
| <a id="workitemtypeconnectionnodes"></a>`nodes` | [`[WorkItemType]`](#workitemtype) | A list of nodes. |
| <a id="workitemtypeconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
#### `WorkItemTypeEdge`
The edge type for [`WorkItemType`](#workitemtype).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemtypeedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="workitemtypeedgenode"></a>`node` | [`WorkItemType`](#workitemtype) | The item at the end of the edge. |
## Object types
Object types represent the resources that the GitLab GraphQL API can return.
......@@ -10608,6 +10631,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="groupvisibility"></a>`visibility` | [`String`](#string) | Visibility of the namespace. |
| <a id="groupvulnerabilityscanners"></a>`vulnerabilityScanners` | [`VulnerabilityScannerConnection`](#vulnerabilityscannerconnection) | Vulnerability scanners reported on the project vulnerabilities of the group and its subgroups. (see [Connections](#connections)) |
| <a id="groupweburl"></a>`webUrl` | [`String!`](#string) | Web URL of the group. |
| <a id="groupworkitemtypes"></a>`workItemTypes` | [`WorkItemTypeConnection`](#workitemtypeconnection) | Work item types available to the group. Available only when feature flag `work_items` is enabled. This flag is disabled by default, because the feature is experimental and is subject to change without notice. (see [Connections](#connections)) |
#### Fields with arguments
......@@ -15978,6 +16002,16 @@ Represents vulnerability letter grades with associated projects.
| <a id="vulnerableprojectsbygradegrade"></a>`grade` | [`VulnerabilityGrade!`](#vulnerabilitygrade) | Grade based on the highest severity vulnerability present. |
| <a id="vulnerableprojectsbygradeprojects"></a>`projects` | [`ProjectConnection!`](#projectconnection) | Projects within this grade. (see [Connections](#connections)) |
### `WorkItemType`
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemtypeiconname"></a>`iconName` | [`String`](#string) | Icon name of the work item type. |
| <a id="workitemtypeid"></a>`id` | [`WorkItemsTypeID!`](#workitemstypeid) | Global ID of the work item type. |
| <a id="workitemtypename"></a>`name` | [`String!`](#string) | Name of the work item type. |
## Enumeration types
Also called _Enums_, enumeration types are a special kind of scalar that
......@@ -18061,6 +18095,12 @@ A `VulnerabilityID` is a global ID. It is encoded as a string.
An example `VulnerabilityID` is: `"gid://gitlab/Vulnerability/1"`.
### `WorkItemsTypeID`
A `WorkItemsTypeID` is a global ID. It is encoded as a string.
An example `WorkItemsTypeID` is: `"gid://gitlab/WorkItems::Type/1"`.
## Abstract types
Abstract types (unions and interfaces) are ways the schema can represent
......@@ -10,7 +10,7 @@ module IssueWidgets
after_validation :invalidate_if_sync_error, on: [:update, :create]
# This will mean that non-Requirement issues essentially ignore this relationship and always return []
has_many :test_reports, -> { joins(:requirement_issue).where(issues: { issue_type: WorkItem::Type.base_types[:requirement] }) },
has_many :test_reports, -> { joins(:requirement_issue).where(issues: { issue_type: WorkItems::Type.base_types[:requirement] }) },
foreign_key: :issue_id, inverse_of: :requirement_issue, class_name: 'RequirementsManagement::TestReport'
has_one :requirement, class_name: 'RequirementsManagement::Requirement'
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['WorkItemType'] do
let(:fields) do
%i[id icon_name name]
end
specify { expect(described_class.graphql_name).to eq('WorkItemType') }
specify { expect(described_class).to have_graphql_fields(fields) }
specify { expect(described_class).to require_graphql_authorizations(:read_work_item_type) }
end
......@@ -56,7 +56,7 @@ RSpec.describe Issues::BuildService do
end
context 'as developer' do
WorkItem::Type.allowed_types_for_issues do |issue_type|
WorkItems::Type.allowed_types_for_issues do |issue_type|
it "sets the issue type to #{issue_type}" do
issue = build_issue(issue_type: issue_type)
......
......@@ -23,7 +23,7 @@ module API
expose :issue_type,
as: :type,
format_with: :upcase,
documentation: { type: "String", desc: "One of #{::WorkItem::Type.allowed_types_for_issues.map(&:upcase)}" }
documentation: { type: "String", desc: "One of #{::WorkItems::Type.allowed_types_for_issues.map(&:upcase)}" }
expose :assignee, using: ::API::Entities::UserBasic do |issue|
issue.assignees.first
......
......@@ -82,7 +82,7 @@ module API
desc: 'Return issues sorted in `asc` or `desc` order.'
optional :due_date, type: String, values: %w[0 overdue week month next_month_and_previous_two_weeks] << '',
desc: 'Return issues that have no due date (`0`), or whose due date is this week, this month, between two weeks ago and next month, or which are overdue. Accepts: `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`, `0`'
optional :issue_type, type: String, values: WorkItem::Type.allowed_types_for_issues, desc: "The type of the issue. Accepts: #{WorkItem::Type.allowed_types_for_issues.join(', ')}"
optional :issue_type, type: String, values: WorkItems::Type.allowed_types_for_issues, desc: "The type of the issue. Accepts: #{WorkItems::Type.allowed_types_for_issues.join(', ')}"
use :issues_stats_params
use :pagination
......@@ -99,7 +99,7 @@ module API
optional :due_date, type: String, desc: 'Date string in the format YEAR-MONTH-DAY'
optional :confidential, type: Boolean, desc: 'Boolean parameter if the issue should be confidential'
optional :discussion_locked, type: Boolean, desc: " Boolean parameter indicating if the issue's discussion is locked"
optional :issue_type, type: String, values: WorkItem::Type.allowed_types_for_issues, desc: "The type of the issue. Accepts: #{WorkItem::Type.allowed_types_for_issues.join(', ')}"
optional :issue_type, type: String, values: WorkItems::Type.allowed_types_for_issues, desc: "The type of the issue. Accepts: #{WorkItems::Type.allowed_types_for_issues.join(', ')}"
use :optional_issue_params_ee
end
......
......@@ -5,8 +5,8 @@ module Gitlab
module WorkItems
module BaseTypeImporter
def self.import
WorkItem::Type::BASE_TYPES.each do |type, attributes|
WorkItem::Type.create!(base_type: type, **attributes.slice(:name, :icon_name))
::WorkItems::Type::BASE_TYPES.each do |type, attributes|
::WorkItems::Type.create!(base_type: type, **attributes.slice(:name, :icon_name))
end
end
end
......
# frozen_string_literal: true
FactoryBot.define do
factory :work_item_type, class: 'WorkItem::Type' do
factory :work_item_type, class: 'WorkItems::Type' do
namespace
name { generate(:work_item_type_name) }
base_type { WorkItem::Type.base_types[:issue] }
base_type { WorkItems::Type.base_types[:issue] }
icon_name { 'issue-type-issue' }
initialize_with do
......@@ -13,9 +13,9 @@ FactoryBot.define do
# Expect base_types to exist on the DB
if type_base_attributes.slice(:namespace, :namespace_id).compact.empty?
WorkItem::Type.find_or_initialize_by(type_base_attributes).tap { |type| type.assign_attributes(attributes) }
WorkItems::Type.find_or_initialize_by(type_base_attributes).tap { |type| type.assign_attributes(attributes) }
else
WorkItem::Type.new(attributes)
WorkItems::Type.new(attributes)
end
end
......@@ -24,17 +24,17 @@ FactoryBot.define do
end
trait :incident do
base_type { WorkItem::Type.base_types[:incident] }
base_type { WorkItems::Type.base_types[:incident] }
icon_name { 'issue-type-incident' }
end
trait :test_case do
base_type { WorkItem::Type.base_types[:test_case] }
base_type { WorkItems::Type.base_types[:test_case] }
icon_name { 'issue-type-test-case' }
end
trait :requirement do
base_type { WorkItem::Type.base_types[:requirement] }
base_type { WorkItems::Type.base_types[:requirement] }
icon_name { 'issue-type-requirements' }
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::WorkItems::TypesResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group) }
before_all do
group.add_developer(current_user)
end
describe '#resolve' do
it 'returns all default work item types' do
result = resolve(described_class, obj: group)
expect(result.to_a).to match(WorkItems::Type.default.order_by_name_asc)
end
end
end
......@@ -9,7 +9,7 @@ RSpec.describe IssuesHelper do
describe '#work_item_type_icon' do
it 'returns icon of all standard base types' do
WorkItem::Type.base_types.each do |type|
WorkItems::Type.base_types.each do |type|
expect(work_item_type_icon(type[0])).to eq "issue-type-#{type[0].to_s.dasherize}"
end
end
......
......@@ -15,7 +15,7 @@ RSpec.describe Issue do
it { is_expected.to belong_to(:iteration) }
it { is_expected.to belong_to(:project) }
it { is_expected.to have_one(:namespace).through(:project) }
it { is_expected.to belong_to(:work_item_type).class_name('WorkItem::Type') }
it { is_expected.to belong_to(:work_item_type).class_name('WorkItems::Type') }
it { is_expected.to belong_to(:moved_to).class_name('Issue') }
it { is_expected.to have_one(:moved_from).class_name('Issue') }
it { is_expected.to belong_to(:duplicated_to).class_name('Issue') }
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe WorkItem::Type do
RSpec.describe WorkItems::Type do
describe 'modules' do
it { is_expected.to include_module(CacheMarkdownField) }
end
......@@ -12,6 +12,22 @@ RSpec.describe WorkItem::Type do
it { is_expected.to belong_to(:namespace) }
end
describe 'scopes' do
describe 'order_by_name_asc' do
subject { described_class.order_by_name_asc.pluck(:name) }
before do
# Deletes all so we have control on the entire list of names
described_class.delete_all
create(:work_item_type, name: 'Ztype')
create(:work_item_type, name: 'atype')
create(:work_item_type, name: 'gtype')
end
it { is_expected.to match(%w[atype gtype Ztype]) }
end
end
describe '#destroy' do
let!(:work_item) { create :issue }
......@@ -19,10 +35,10 @@ RSpec.describe WorkItem::Type do
it 'deletes type but not unrelated issues' do
type = create(:work_item_type)
expect(WorkItem::Type.count).to eq(6)
expect(WorkItems::Type.count).to eq(6)
expect { type.destroy! }.not_to change(Issue, :count)
expect(WorkItem::Type.count).to eq(5)
expect(WorkItems::Type.count).to eq(5)
end
end
......@@ -44,6 +60,22 @@ RSpec.describe WorkItem::Type do
it { is_expected.not_to allow_value('s' * 256).for(:icon_name) }
end
describe 'default?' do
subject { build(:work_item_type, namespace: namespace).default? }
context 'when namespace is nil' do
let(:namespace) { nil }
it { is_expected.to be_truthy }
end
context 'when namespace is present' do
let(:namespace) { build(:namespace) }
it { is_expected.to be_falsey }
end
end
describe '#name' do
it 'strips name' do
work_item_type = described_class.new(name: ' label😸 ')
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'getting a list of work item types for a group' do
include GraphqlHelpers
let_it_be(:developer) { create(:user) }
let_it_be(:group) { create(:group, :private) }
before_all do
group.add_developer(developer)
end
let(:current_user) { developer }
let(:fields) do
<<~GRAPHQL
workItemTypes{
nodes { id name iconName }
}
GRAPHQL
end
let(:query) do
graphql_query_for(
'group',
{ 'fullPath' => group.full_path },
fields
)
end
context 'when user has access to the group' do
before do
post_graphql(query, current_user: current_user)
end
it_behaves_like 'a working graphql query'
it 'returns all default work item types' do
expect(graphql_data.dig('group', 'workItemTypes', 'nodes')).to match_array(
WorkItems::Type.default.map do |type|
hash_including('id' => type.to_global_id.to_s, 'name' => type.name, 'iconName' => type.icon_name)
end
)
end
end
context "when user doesn't have acces to the group" do
let(:current_user) { create(:user) }
before do
post_graphql(query, current_user: current_user)
end
it 'does not return the group' do
expect(graphql_data).to eq('group' => nil)
end
end
context 'when the work_items feature flag is disabled' do
before do
stub_feature_flags(work_items: false)
post_graphql(query, current_user: current_user)
end
it 'makes the workItemTypes field unavailable' do
expect(graphql_errors).to contain_exactly(hash_including("message" => "Field 'workItemTypes' doesn't exist on type 'Group'"))
end
end
end
......@@ -172,9 +172,9 @@ RSpec.describe Issues::BuildService do
end
describe 'setting issue type' do
context 'with a corresponding WorkItem::Type' do
let_it_be(:type_issue_id) { WorkItem::Type.default_issue_type.id }
let_it_be(:type_incident_id) { WorkItem::Type.default_by_type(:incident).id }
context 'with a corresponding WorkItems::Type' do
let_it_be(:type_issue_id) { WorkItems::Type.default_issue_type.id }
let_it_be(:type_incident_id) { WorkItems::Type.default_by_type(:incident).id }
where(:issue_type, :current_user, :work_item_type_id, :resulting_issue_type) do
nil | ref(:guest) | ref(:type_issue_id) | 'issue'
......
......@@ -3,8 +3,8 @@
RSpec.shared_examples 'work item base types importer' do
it 'creates all base work item types' do
# Fixtures need to run on a pristine DB, but the test suite preloads the base types before(:suite)
WorkItem::Type.delete_all
WorkItems::Type.delete_all
expect { subject }.to change(WorkItem::Type, :count).from(0).to(WorkItem::Type::BASE_TYPES.count)
expect { subject }.to change(WorkItems::Type, :count).from(0).to(WorkItems::Type::BASE_TYPES.count)
end
end
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