Commit 36520e30 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '233565-cablett-create-epic-via-list' into 'master'

Create epics via epic board list

See merge request gitlab-org/gitlab!62455
parents ea1c250c df231ee2
...@@ -713,6 +713,28 @@ Input type: `AwardEmojiToggleInput` ...@@ -713,6 +713,28 @@ Input type: `AwardEmojiToggleInput`
| <a id="mutationawardemojitoggleerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationawardemojitoggleerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationawardemojitoggletoggledon"></a>`toggledOn` | [`Boolean!`](#boolean) | Indicates the status of the emoji. True if the toggle awarded the emoji, and false if the toggle removed the emoji. | | <a id="mutationawardemojitoggletoggledon"></a>`toggledOn` | [`Boolean!`](#boolean) | Indicates the status of the emoji. True if the toggle awarded the emoji, and false if the toggle removed the emoji. |
### `Mutation.boardEpicCreate`
Input type: `BoardEpicCreateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationboardepiccreateboardid"></a>`boardId` | [`BoardsEpicBoardID!`](#boardsepicboardid) | Global ID of the board that the epic is in. |
| <a id="mutationboardepiccreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationboardepiccreategrouppath"></a>`groupPath` | [`ID!`](#id) | Group the epic to create is in. |
| <a id="mutationboardepiccreatelistid"></a>`listId` | [`BoardsEpicListID!`](#boardsepiclistid) | Global ID of the epic board list in which epic will be created. |
| <a id="mutationboardepiccreatetitle"></a>`title` | [`String!`](#string) | Title of the epic. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationboardepiccreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationboardepiccreateepic"></a>`epic` | [`Epic`](#epic) | Epic after creation. |
| <a id="mutationboardepiccreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
### `Mutation.boardListCreate` ### `Mutation.boardListCreate`
Input type: `BoardListCreateInput` Input type: `BoardListCreateInput`
......
...@@ -47,6 +47,7 @@ module EE ...@@ -47,6 +47,7 @@ module EE
mount_mutation ::Mutations::Boards::EpicLists::Create mount_mutation ::Mutations::Boards::EpicLists::Create
mount_mutation ::Mutations::Boards::EpicLists::Destroy mount_mutation ::Mutations::Boards::EpicLists::Destroy
mount_mutation ::Mutations::Boards::EpicLists::Update mount_mutation ::Mutations::Boards::EpicLists::Update
mount_mutation ::Mutations::Boards::Epics::Create
mount_mutation ::Mutations::Boards::Lists::UpdateLimitMetrics 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
......
# frozen_string_literal: true
module Mutations
module Boards
module Epics
class Create < ::Mutations::BaseMutation
include Mutations::ResolvesGroup
graphql_name 'BoardEpicCreate'
argument :group_path, GraphQL::ID_TYPE,
required: true,
description: 'Group the epic to create is in.'
argument :board_id, ::Types::GlobalIDType[::Boards::EpicBoard],
required: true,
description: 'Global ID of the board that the epic is in.'
argument :list_id, ::Types::GlobalIDType[::Boards::EpicList],
required: true,
description: 'Global ID of the epic board list in which epic will be created.'
argument :title,
GraphQL::STRING_TYPE,
required: true,
description: 'Title of the epic.'
field :epic,
Types::EpicType,
null: true,
description: 'Epic after creation.'
authorize :create_epic
def resolve(**args)
group_path = args.delete(:group_path)
group = authorized_find!(group_path: group_path)
response = ::Boards::Epics::CreateService.new(group, current_user, create_epic_params(args)).execute
mutation_response(response)
end
private
def mutation_response(service_response)
{
epic: service_response.success? ? service_response.payload : nil,
errors: Array.wrap(service_response.errors)
}
end
def find_object(group_path:)
resolve_group(full_path: group_path)
end
def create_epic_params(args)
args[:list_id] &&= ::GitlabSchema.parse_gid(args[:list_id], expected_type: ::Boards::EpicList).model_id
args[:board_id] &&= ::GitlabSchema.parse_gid(args[:board_id], expected_type: ::Boards::EpicBoard).model_id
args.with_indifferent_access
end
end
end
end
end
# frozen_string_literal: true
module Boards
module Epics
class CreateService < Boards::BaseService
def initialize(parent, user, params = {})
@group = parent
super(parent, user, params)
end
def execute
return ServiceResponse.error(message: 'This feature is not available') unless available?
return ServiceResponse.error(message: "The resource that you are attempting to access does not exist or you don't have permission to perform this action") unless allowed?
error = check_arguments
if error
return ServiceResponse.error(message: error)
end
epic = ::Epics::CreateService.new(group: group, current_user: current_user, params: params.merge(epic_params)).execute
return ServiceResponse.success(payload: epic) if epic.persisted?
ServiceResponse.error(message: epic.errors.full_messages.join(", "))
end
private
alias_method :group, :parent
def epic_params
{ label_ids: [list.label_id] }
end
def board
@board ||= parent.epic_boards.find(params.delete(:board_id))
end
def list
@list ||= board.lists.find(params.delete(:list_id))
end
def available?
group.licensed_feature_available?(:epics) && Feature.enabled?(:epic_boards, parent)
end
def allowed?
Ability.allowed?(current_user, :create_epic, group)
end
def check_arguments
begin
board
rescue ActiveRecord::RecordNotFound
return 'Board not found' if @board.blank?
end
begin
list
rescue ActiveRecord::RecordNotFound
return 'List not found' if @list.blank?
end
nil
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Boards::Epics::Create do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:board) { create(:epic_board, group: group) }
let_it_be(:label) do
create(:group_label, title: 'Doing', color: '#FFAABB', group: group)
end
let_it_be(:list) { create(:epic_list, epic_board: board, label: label) }
let(:title) { "The Illiad" }
before do
stub_licensed_features(epics: true)
stub_feature_flags(epic_boards: true)
end
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
let(:default_params) { { group_path: group.path, board_id: global_id_of(board), list_id: global_id_of(list), title: title } }
let(:epic_create_params) { default_params }
subject { mutation.resolve(**epic_create_params) }
context 'field tests' do
subject { described_class }
it { is_expected.to have_graphql_arguments(:boardId, :listId, :title, :groupPath) }
it { is_expected.to have_graphql_fields(:epic).at_least }
end
shared_examples 'epic creation error' do
it 'raises error' do
expect { subject }
.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
describe '#resolve' do
context 'with proper permissions' do
before_all do
group.add_maintainer(user)
end
describe 'create epic via label list' do
it 'creates a new epic' do
expect { subject }.to change { Epic.count }.by(1)
end
it 'creates and returns a new epic with that label', :aggregate_failures do
new_epic = subject[:epic]
expect(new_epic.title).to eq title
expect(new_epic.labels).to eq [label]
end
context 'when group not found' do
let(:epic_create_params) { default_params.merge({ group_path: "nonsense" }) }
it_behaves_like 'epic creation error'
end
context 'when board not found' do
let(:epic_create_params) { default_params.merge({ board_id: "gid://gitlab/Boards::EpicBoard/#{non_existing_record_id}" })}
it 'returns an error' do
expect(subject[:errors]).to include "Board not found"
end
end
context 'when list not found' do
let(:epic_create_params) { default_params.merge({ list_id: "gid://gitlab/Boards::EpicList/#{non_existing_record_id}" })}
it 'returns an error' do
expect(subject[:errors]).to include "List not found"
end
end
context 'when list is not under that board' do
let_it_be(:other_board_list) { create(:epic_list) }
let(:epic_create_params) { default_params.merge({ list_id: "gid://gitlab/Boards::EpicList/#{other_board_list.id}" })}
it 'returns an error' do
expect(subject[:errors]).to include "List not found"
end
end
context 'when title empty' do
let(:epic_create_params) { default_params.merge({ title: "" }) }
it 'returns an error' do
expect(subject[:errors]).to include "Title can't be blank"
end
end
context 'when title nil' do
let(:epic_create_params) { default_params.merge({ title: nil }) }
it 'returns an error' do
expect(subject[:errors]).to include "Title can't be blank"
end
end
end
context 'with epic boards disabled' do
before do
stub_feature_flags(epic_boards: false)
end
it 'returns an error' do
expect(subject[:errors]).to include 'This feature is not available'
end
end
context 'with epics not available' do
before do
stub_licensed_features(epics: false)
end
it_behaves_like 'epic creation error'
end
end
context 'without proper permissions' do
before_all do
group.add_guest(user)
end
it_behaves_like 'epic creation error'
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Boards::Epics::Create do
include GraphqlHelpers
let_it_be(:current_user, reload: true) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:board) { create(:epic_board, group: group) }
let_it_be(:list) { create(:epic_list, epic_board: board) }
let(:params) do
{
group_path: group.full_path,
board_id: global_id_of(board),
list_id: global_id_of(list),
title: title
}
end
let(:title) { 'The Odyssey' }
let(:mutation) { graphql_mutation(:board_epic_create, params) }
subject { post_graphql_mutation(mutation, current_user: current_user) }
def mutation_response
graphql_mutation_response(:board_epic_create)
end
shared_examples 'does not create an epic' do
specify do
expect { subject }.not_to change { Board.count }
end
end
context 'when the user does not have permission' do
it_behaves_like 'a mutation that returns a top-level access error'
it_behaves_like 'does not create an epic'
end
context 'when the user has permission' do
before do
group.add_reporter(current_user)
stub_licensed_features(epics: true)
stub_feature_flags(epic_boards: true)
end
context 'when all arguments are given' do
context 'when everything is ok' do
it 'creates the epic' do
expect { subject }.to change { Epic.count }.from(0).to(1)
end
it 'returns the created epic' do
subject
expect(mutation_response).to have_key('epic')
expect(mutation_response['epic']['title']).to eq(title)
end
end
context 'when arguments are nil resulting in a top level error' do
before do
params[:board_id] = nil
end
it_behaves_like 'does not create an epic'
it_behaves_like 'a mutation that returns top-level errors' do
let(:match_errors) { include(/boardId \(Expected value to not be null\)/) }
end
end
context 'when argument is blank resulting in an ActiveRecord error' do
before do
params[:title] = ""
end
it_behaves_like 'does not create an epic'
it 'returns an error' do
subject
expect(mutation_response['epic']).to be_nil
expect(mutation_response['errors'].first).to eq("Title can't be blank")
end
end
end
context 'when arguments are missing' do
let(:params) { { title: title } }
it_behaves_like 'a mutation that returns top-level errors' do
let(:match_errors) { include(/boardId \(Expected value to not be null\)/) }
end
it_behaves_like 'does not create an epic'
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Boards::Epics::CreateService do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:board) { create(:epic_board, group: group) }
describe '#execute' do
let_it_be(:development) { create(:group_label, group: group, name: 'Development') }
let_it_be(:backlog) { create(:epic_list, epic_board: board, list_type: :backlog) }
let_it_be(:list) { create(:epic_list, epic_board: board, label: development, position: 0) }
let(:valid_params) do
{ board_id: board.id, list_id: list.id, title: 'Gilgamesh' }
end
let(:params) { valid_params }
subject do
described_class.new(group, user, params).execute
end
shared_examples 'epic creation error' do |error_pattern|
it 'does not create epic' do
response = subject
expect(response).to be_error
expect(response.message).to match(error_pattern)
end
end
context 'when epics feature is available' do
before do
stub_licensed_features(epics: true)
group.add_developer(user)
end
context 'when arguments are valid' do
it 'creates an epic' do
response = subject
expect(response).to be_success
expect(response.payload).to be_a(Epic)
end
specify { expect { subject }.to change { Epic.count }.by(1) }
end
context 'when arguments are not valid' do
let_it_be(:other_board) { create(:epic_board) }
let_it_be(:other_board_list) { create(:epic_list, epic_board: other_board) }
context 'when board id is bogus' do
let(:params) { valid_params.merge(board_id: non_existing_record_id) }
it_behaves_like 'epic creation error', /Board not found/
end
context 'when list id is for a different board' do
let(:params) { valid_params.merge(list_id: other_board_list.id) }
it_behaves_like 'epic creation error', /List not found/
end
context 'when board id is for a different group' do
let(:params) { valid_params.merge(board_id: other_board.id) }
it_behaves_like 'epic creation error', /Board not found/
end
end
end
context 'when epics feature is not available' do
it_behaves_like 'epic creation error', /does not exist or you don't have permission/
end
context 'when epic boards feature flag is not enabled' do
before do
stub_feature_flags(epic_boards: false)
end
it_behaves_like 'epic creation error', /not available/
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