Commit 06ae3bfb authored by Andy Soiron's avatar Andy Soiron

Merge branch '346041-work-item-types-resolver' into 'master'

Add workItemTypes field to GraphqQL GroupType

See merge request gitlab-org/gitlab!76803
parents 40080f73 7c4ca6fa
......@@ -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.
......@@ -10609,6 +10632,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
......@@ -15979,6 +16003,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
......@@ -18062,6 +18096,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