Commit b3bdd985 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '233434-cablett-create_epic_board' into 'master'

Create epic board via GraphQL

See merge request gitlab-org/gitlab!52258
parents 81628d41 7b48be3a
...@@ -13,11 +13,11 @@ module Boards ...@@ -13,11 +13,11 @@ module Boards
private private
def can_create_board? def can_create_board?
parent.boards.empty? || parent.multiple_issue_boards_available? parent_board_collection.empty? || parent.multiple_issue_boards_available?
end end
def create_board! def create_board!
board = parent.boards.create(params) board = parent_board_collection.create(params)
unless board.persisted? unless board.persisted?
return ServiceResponse.error(message: "There was an error when creating a board.", payload: board) return ServiceResponse.error(message: "There was an error when creating a board.", payload: board)
...@@ -30,6 +30,10 @@ module Boards ...@@ -30,6 +30,10 @@ module Boards
ServiceResponse.success(payload: board) ServiceResponse.success(payload: board)
end end
def parent_board_collection
parent.boards
end
end end
end end
......
...@@ -8967,6 +8967,56 @@ type EpicBoardConnection { ...@@ -8967,6 +8967,56 @@ type EpicBoardConnection {
pageInfo: PageInfo! pageInfo: PageInfo!
} }
"""
Autogenerated input type of EpicBoardCreate
"""
input EpicBoardCreateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Full path of the group with which the resource is associated.
"""
groupPath: ID
"""
Whether or not backlog list is hidden.
"""
hideBacklogList: Boolean
"""
Whether or not closed list is hidden.
"""
hideClosedList: Boolean
"""
The board name.
"""
name: String
}
"""
Autogenerated return type of EpicBoardCreate
"""
type EpicBoardCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The created epic board.
"""
epicBoard: EpicBoard
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
""" """
An edge in a connection. An edge in a connection.
""" """
...@@ -16051,6 +16101,7 @@ type Mutation { ...@@ -16051,6 +16101,7 @@ type Mutation {
dismissVulnerability(input: DismissVulnerabilityInput!): DismissVulnerabilityPayload @deprecated(reason: "Use vulnerabilityDismiss. Deprecated in 13.5.") dismissVulnerability(input: DismissVulnerabilityInput!): DismissVulnerabilityPayload @deprecated(reason: "Use vulnerabilityDismiss. Deprecated in 13.5.")
environmentsCanaryIngressUpdate(input: EnvironmentsCanaryIngressUpdateInput!): EnvironmentsCanaryIngressUpdatePayload environmentsCanaryIngressUpdate(input: EnvironmentsCanaryIngressUpdateInput!): EnvironmentsCanaryIngressUpdatePayload
epicAddIssue(input: EpicAddIssueInput!): EpicAddIssuePayload epicAddIssue(input: EpicAddIssueInput!): EpicAddIssuePayload
epicBoardCreate(input: EpicBoardCreateInput!): EpicBoardCreatePayload
epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload
epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload
exportRequirements(input: ExportRequirementsInput!): ExportRequirementsPayload exportRequirements(input: ExportRequirementsInput!): ExportRequirementsPayload
......
...@@ -24811,6 +24811,134 @@ ...@@ -24811,6 +24811,134 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INPUT_OBJECT",
"name": "EpicBoardCreateInput",
"description": "Autogenerated input type of EpicBoardCreate",
"fields": null,
"inputFields": [
{
"name": "name",
"description": "The board name.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "hideBacklogList",
"description": "Whether or not backlog list is hidden.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null
},
{
"name": "hideClosedList",
"description": "Whether or not closed list is hidden.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null
},
{
"name": "groupPath",
"description": "Full path of the group with which the resource is associated.",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EpicBoardCreatePayload",
"description": "Autogenerated return type of EpicBoardCreate",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "epicBoard",
"description": "The created epic board.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "EpicBoard",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "EpicBoardEdge", "name": "EpicBoardEdge",
...@@ -45574,6 +45702,33 @@ ...@@ -45574,6 +45702,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "epicBoardCreate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "EpicBoardCreateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "EpicBoardCreatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "epicSetSubscription", "name": "epicSetSubscription",
"description": null, "description": null,
...@@ -1448,6 +1448,16 @@ Represents an epic board. ...@@ -1448,6 +1448,16 @@ Represents an epic board.
| `lists` | EpicListConnection | Epic board lists. | | `lists` | EpicListConnection | Epic board lists. |
| `name` | String | Name of the board. | | `name` | String | Name of the board. |
### EpicBoardCreatePayload
Autogenerated return type of EpicBoardCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `epicBoard` | EpicBoard | The created epic board. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### EpicDescendantCount ### EpicDescendantCount
Counts of descendent epics. Counts of descendent epics.
......
...@@ -35,8 +35,9 @@ module EE ...@@ -35,8 +35,9 @@ module EE
mount_mutation ::Mutations::Vulnerabilities::CreateExternalIssueLink mount_mutation ::Mutations::Vulnerabilities::CreateExternalIssueLink
mount_mutation ::Mutations::Vulnerabilities::DestroyExternalIssueLink mount_mutation ::Mutations::Vulnerabilities::DestroyExternalIssueLink
mount_mutation ::Mutations::Boards::Update mount_mutation ::Mutations::Boards::Update
mount_mutation ::Mutations::Boards::Lists::UpdateLimitMetrics
mount_mutation ::Mutations::Boards::UpdateEpicUserPreferences mount_mutation ::Mutations::Boards::UpdateEpicUserPreferences
mount_mutation ::Mutations::Boards::EpicBoards::Create
mount_mutation ::Mutations::Boards::Lists::UpdateLimitMetrics
mount_mutation ::Mutations::InstanceSecurityDashboard::AddProject mount_mutation ::Mutations::InstanceSecurityDashboard::AddProject
mount_mutation ::Mutations::InstanceSecurityDashboard::RemoveProject mount_mutation ::Mutations::InstanceSecurityDashboard::RemoveProject
mount_mutation ::Mutations::DastOnDemandScans::Create mount_mutation ::Mutations::DastOnDemandScans::Create
......
# frozen_string_literal: true
module Mutations
module Boards
module EpicBoards
class Create < ::Mutations::BaseMutation
include Mutations::ResolvesGroup
include Mutations::Boards::CommonMutationArguments
graphql_name 'EpicBoardCreate'
authorize :admin_epic_board
argument :group_path, GraphQL::ID_TYPE,
required: false,
description: 'Full path of the group with which the resource is associated.'
field :epic_board,
Types::Boards::EpicBoardType,
null: true,
description: 'The created epic board.'
def resolve(args)
group_path = args.delete(:group_path)
group = authorized_find!(group_path: group_path)
service_response = ::Boards::EpicBoards::CreateService.new(group, current_user, args).execute
{
epic_board: service_response.payload,
errors: service_response.errors
}
end
private
def find_object(group_path:)
resolve_group(full_path: group_path)
end
end
end
end
end
...@@ -7,7 +7,7 @@ module Boards ...@@ -7,7 +7,7 @@ module Boards
has_many :epic_board_positions, foreign_key: :epic_board_id, inverse_of: :epic_board has_many :epic_board_positions, foreign_key: :epic_board_id, inverse_of: :epic_board
has_many :epic_lists, -> { ordered }, foreign_key: :epic_board_id, inverse_of: :epic_board has_many :epic_lists, -> { ordered }, foreign_key: :epic_board_id, inverse_of: :epic_board
validates :name, length: { maximum: 255 } validates :name, length: { maximum: 255 }, presence: true
scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc).order(id: :asc) } scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc).order(id: :asc) }
......
...@@ -197,6 +197,7 @@ module EE ...@@ -197,6 +197,7 @@ module EE
enable :update_epic enable :update_epic
enable :read_confidential_epic enable :read_confidential_epic
enable :destroy_epic_link enable :destroy_epic_link
enable :admin_epic_board
end end
rule { reporter & subepics_available }.policy do rule { reporter & subepics_available }.policy do
......
# frozen_string_literal: true
module Boards
module EpicBoards
class CreateService < Boards::CreateService
extend ::Gitlab::Utils::Override
override :can_create_board?
def can_create_board?
Feature.enabled?(:epic_boards, parent)
end
override :parent_board_collection
def parent_board_collection
parent.epic_boards
end
end
end
end
---
title: Add create epic board via GraphQL
merge_request: 52258
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Mutations::Boards::EpicBoards::Create do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
let(:name) { 'A glorious epic board' }
subject { mutation.resolve(group_path: group.full_path, name: name) }
shared_examples 'epic board creation error' do
it 'raises error' do
expect { mutation.resolve(group_path: group.full_path, name: name) }
.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'field tests' do
subject { described_class }
it { is_expected.to have_graphql_arguments(:groupPath, :name, :hideBacklogList, :hideClosedList) }
it { is_expected.to have_graphql_fields(:epic_board).at_least }
end
context 'with epic feature enabled and epic_boards feature flag enabled' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(epic_boards: true)
end
context 'when user does not have permission to create epic board' do
it_behaves_like 'epic board creation error'
end
context 'when user has permission to create epic board' do
before do
group.add_reporter(current_user)
end
it 'creates an epic board' do
result = mutation.resolve(group_path: group.full_path, name: name)
expect(result[:epic_board]).to be_valid
expect(result[:epic_board].group).to eq(group)
expect(result[:epic_board].name).to eq(name)
end
end
end
context 'with epic_boards feature flag disabled' do
before do
stub_feature_flags(epic_boards: false)
end
it_behaves_like 'epic board creation error'
end
context 'with epic feature disabled' do
before do
stub_licensed_features(epics: false)
end
it_behaves_like 'epic board creation error'
end
end
...@@ -7,7 +7,7 @@ RSpec.describe GroupPolicy do ...@@ -7,7 +7,7 @@ RSpec.describe GroupPolicy do
let(:epic_rules) do let(:epic_rules) do
%i(read_epic create_epic admin_epic destroy_epic read_confidential_epic %i(read_epic create_epic admin_epic destroy_epic read_confidential_epic
destroy_epic_link read_epic_board read_epic_list) destroy_epic_link read_epic_board read_epic_list admin_epic_board)
end end
context 'when epics feature is disabled' do context 'when epics feature is disabled' do
......
...@@ -13,60 +13,7 @@ RSpec.describe Boards::CreateService, services: true do ...@@ -13,60 +13,7 @@ RSpec.describe Boards::CreateService, services: true do
stub_licensed_features(multiple_group_issue_boards: true) stub_licensed_features(multiple_group_issue_boards: true)
end end
context 'with valid params' do it_behaves_like 'create a board', :boards
subject(:service) { described_class.new(parent, double, name: 'Backend') }
it 'creates a new board' do
expect { service.execute }.to change(parent.boards, :count).by(1)
end
it 'returns a successful response' do
expect(service.execute).to be_success
end
it 'creates the default lists' do
board = created_board
expect(board.lists.size).to eq 2
expect(board.lists.first).to be_backlog
expect(board.lists.last).to be_closed
end
end
context 'with invalid params' do
subject(:service) { described_class.new(parent, double, name: nil) }
it 'does not create a new parent board' do
expect { service.execute }.not_to change(parent.boards, :count)
end
it 'returns an error response' do
expect(service.execute).to be_error
end
it "does not create board's default lists" do
expect(created_board.lists.size).to eq 0
end
end
context 'without params' do
subject(:service) { described_class.new(parent, double) }
it 'creates a new parent board' do
expect { service.execute }.to change(parent.boards, :count).by(1)
end
it 'returns a successful response' do
expect(service.execute).to be_success
end
it "creates board's default lists" do
board = created_board
expect(board.lists.size).to eq 2
expect(board.lists.last).to be_closed
end
end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Boards::EpicBoards::CreateService, services: true do
def created_board
service.execute.payload
end
let(:parent) { create(:group) }
let(:epic_boards_enabled) { false }
before do
stub_feature_flags(epic_boards: epic_boards_enabled)
end
context 'with epic boards feature not available' do
it 'does not create a board' do
service = described_class.new(parent, double)
expect(service.execute.payload).not_to be_nil
expect { service.execute }.not_to change(parent.epic_boards, :count)
end
end
context 'with epic boards feature available' do
let(:epic_boards_enabled) { true }
it_behaves_like 'create a board', :epic_boards
end
end
# frozen_string_literal: true
RSpec.shared_examples 'create a board' do |scope|
context 'with valid params' do
subject(:service) { described_class.new(parent, double, name: 'Backend') }
it 'creates a new board' do
expect { service.execute }.to change(parent.send(scope), :count).by(1)
end
it 'returns a successful response' do
expect(service.execute).to be_success
end
it 'creates the default lists' do
board = created_board
expect(board.lists.size).to eq 2
expect(board.lists.first).to be_backlog
expect(board.lists.last).to be_closed
end
end
context 'with invalid params' do
subject(:service) { described_class.new(parent, double, name: nil) }
it 'does not create a new parent board' do
expect { service.execute }.not_to change(parent.send(scope), :count)
end
it 'returns an error response' do
expect(service.execute).to be_error
end
it "does not create board's default lists" do
expect(created_board.lists.size).to eq 0
end
end
context 'without params' do
subject(:service) { described_class.new(parent, double) }
it 'creates a new parent board' do
expect { service.execute }.to change(parent.send(scope), :count).by(1)
end
it 'returns a successful response' do
expect(service.execute).to be_success
end
it "creates board's default lists" do
board = created_board
expect(board.lists.size).to eq 2
expect(board.lists.last).to be_closed
end
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