Commit 8ab7ffce authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'issue_233479-add-graphql-create-todo-mutation' into 'master'

Allow to create todo on GraphQL

See merge request gitlab-org/gitlab!46029
parents d9823db2 a44e178a
# frozen_string_literal: true
module Mutations
module Todos
class Create < ::Mutations::Todos::Base
graphql_name 'TodoCreate'
authorize :create_todo
argument :target_id,
Types::GlobalIDType[Todoable],
required: true,
description: "The global ID of the to-do item's parent. Issues, merge requests, designs and epics are supported"
field :todo, Types::TodoType,
null: true,
description: 'The to-do created'
def resolve(target_id:)
id = ::Types::GlobalIDType[Todoable].coerce_isolated_input(target_id)
target = authorized_find!(id)
todo = TodoService.new.mark_todo(target, current_user)&.first
errors = errors_on_object(todo) if todo
{
todo: todo,
errors: errors
}
end
private
def find_object(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
...@@ -63,6 +63,7 @@ module Types ...@@ -63,6 +63,7 @@ module Types
mount_mutation Mutations::Terraform::State::Delete mount_mutation Mutations::Terraform::State::Delete
mount_mutation Mutations::Terraform::State::Lock mount_mutation Mutations::Terraform::State::Lock
mount_mutation Mutations::Terraform::State::Unlock mount_mutation Mutations::Terraform::State::Unlock
mount_mutation Mutations::Todos::Create
mount_mutation Mutations::Todos::MarkDone mount_mutation Mutations::Todos::MarkDone
mount_mutation Mutations::Todos::Restore mount_mutation Mutations::Todos::Restore
mount_mutation Mutations::Todos::MarkAllDone mount_mutation Mutations::Todos::MarkAllDone
......
# frozen_string_literal: true
# == Todoable concern
#
# Specify object types that supports todos.
#
# Used by Issue, MergeRequest, Design and Epic.
#
module Todoable
end
...@@ -10,6 +10,7 @@ module DesignManagement ...@@ -10,6 +10,7 @@ module DesignManagement
include Mentionable include Mentionable
include WhereComposite include WhereComposite
include RelativePositioning include RelativePositioning
include Todoable
belongs_to :project, inverse_of: :designs belongs_to :project, inverse_of: :designs
belongs_to :issue belongs_to :issue
......
...@@ -21,6 +21,7 @@ class Issue < ApplicationRecord ...@@ -21,6 +21,7 @@ class Issue < ApplicationRecord
include IdInOrdered include IdInOrdered
include Presentable include Presentable
include IssueAvailableFeatures include IssueAvailableFeatures
include Todoable
DueDateStruct = Struct.new(:title, :name).freeze DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
......
...@@ -22,6 +22,7 @@ class MergeRequest < ApplicationRecord ...@@ -22,6 +22,7 @@ class MergeRequest < ApplicationRecord
include StateEventable include StateEventable
include ApprovableBase include ApprovableBase
include IdInOrdered include IdInOrdered
include Todoable
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
......
...@@ -35,6 +35,10 @@ class IssuePolicy < IssuablePolicy ...@@ -35,6 +35,10 @@ class IssuePolicy < IssuablePolicy
rule { ~can?(:read_design) }.policy do rule { ~can?(:read_design) }.policy do
prevent :move_design prevent :move_design
end end
rule { ~anonymous & can?(:read_issue) }.policy do
enable :create_todo
end
end end
IssuePolicy.prepend_if_ee('EE::IssuePolicy') IssuePolicy.prepend_if_ee('EE::IssuePolicy')
...@@ -14,6 +14,10 @@ class MergeRequestPolicy < IssuablePolicy ...@@ -14,6 +14,10 @@ class MergeRequestPolicy < IssuablePolicy
rule { can?(:update_merge_request) }.policy do rule { can?(:update_merge_request) }.policy do
enable :approve_merge_request enable :approve_merge_request
end end
rule { ~anonymous & can?(:read_merge_request) }.policy do
enable :create_todo
end
end end
MergeRequestPolicy.prepend_if_ee('EE::MergeRequestPolicy') MergeRequestPolicy.prepend_if_ee('EE::MergeRequestPolicy')
---
title: Allow to create todo on GraphQL
merge_request: 46029
author:
type: added
...@@ -12992,6 +12992,7 @@ type Mutation { ...@@ -12992,6 +12992,7 @@ type Mutation {
terraformStateDelete(input: TerraformStateDeleteInput!): TerraformStateDeletePayload terraformStateDelete(input: TerraformStateDeleteInput!): TerraformStateDeletePayload
terraformStateLock(input: TerraformStateLockInput!): TerraformStateLockPayload terraformStateLock(input: TerraformStateLockInput!): TerraformStateLockPayload
terraformStateUnlock(input: TerraformStateUnlockInput!): TerraformStateUnlockPayload terraformStateUnlock(input: TerraformStateUnlockInput!): TerraformStateUnlockPayload
todoCreate(input: TodoCreateInput!): TodoCreatePayload
todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload
todoRestore(input: TodoRestoreInput!): TodoRestorePayload todoRestore(input: TodoRestoreInput!): TodoRestorePayload
todoRestoreMany(input: TodoRestoreManyInput!): TodoRestoreManyPayload todoRestoreMany(input: TodoRestoreManyInput!): TodoRestoreManyPayload
...@@ -20177,6 +20178,41 @@ type TodoConnection { ...@@ -20177,6 +20178,41 @@ type TodoConnection {
pageInfo: PageInfo! pageInfo: PageInfo!
} }
"""
Autogenerated input type of TodoCreate
"""
input TodoCreateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The global ID of the to-do item's parent. Issues, merge requests, designs and epics are supported
"""
targetId: TodoableID!
}
"""
Autogenerated return type of TodoCreate
"""
type TodoCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The to-do created
"""
todo: Todo
}
""" """
An edge in a connection. An edge in a connection.
""" """
...@@ -20344,6 +20380,11 @@ enum TodoTargetEnum { ...@@ -20344,6 +20380,11 @@ enum TodoTargetEnum {
MERGEREQUEST MERGEREQUEST
} }
"""
Identifier of Todoable
"""
scalar TodoableID
""" """
Autogenerated input type of TodosMarkAllDone Autogenerated input type of TodosMarkAllDone
""" """
......
...@@ -37870,6 +37870,33 @@ ...@@ -37870,6 +37870,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "todoCreate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "TodoCreateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "TodoCreatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "todoMarkDone", "name": "todoMarkDone",
"description": null, "description": null,
...@@ -58687,6 +58714,108 @@ ...@@ -58687,6 +58714,108 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INPUT_OBJECT",
"name": "TodoCreateInput",
"description": "Autogenerated input type of TodoCreate",
"fields": null,
"inputFields": [
{
"name": "targetId",
"description": "The global ID of the to-do item's parent. Issues, merge requests, designs and epics are supported",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "TodoableID",
"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": "TodoCreatePayload",
"description": "Autogenerated return type of TodoCreate",
"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": "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
},
{
"name": "todo",
"description": "The to-do created",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Todo",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "TodoEdge", "name": "TodoEdge",
...@@ -59172,6 +59301,16 @@ ...@@ -59172,6 +59301,16 @@
], ],
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "SCALAR",
"name": "TodoableID",
"description": "Identifier of Todoable",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "INPUT_OBJECT", "kind": "INPUT_OBJECT",
"name": "TodosMarkAllDoneInput", "name": "TodosMarkAllDoneInput",
...@@ -2838,6 +2838,16 @@ Representing a todo entry. ...@@ -2838,6 +2838,16 @@ Representing a todo entry.
| `state` | TodoStateEnum! | State of the todo | | `state` | TodoStateEnum! | State of the todo |
| `targetType` | TodoTargetEnum! | Target type of the todo | | `targetType` | TodoTargetEnum! | Target type of the todo |
### TodoCreatePayload
Autogenerated return type of TodoCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `todo` | Todo | The to-do created |
### TodoMarkDonePayload ### TodoMarkDonePayload
Autogenerated return type of TodoMarkDone. Autogenerated return type of TodoMarkDone.
......
...@@ -18,6 +18,7 @@ module EE ...@@ -18,6 +18,7 @@ module EE
include EpicTreeSorting include EpicTreeSorting
include Presentable include Presentable
include IdInOrdered include IdInOrdered
include Todoable
enum state_id: { enum state_id: {
opened: ::Epic.available_states[:opened], opened: ::Epic.available_states[:opened],
......
...@@ -31,4 +31,8 @@ class EpicPolicy < BasePolicy ...@@ -31,4 +31,8 @@ class EpicPolicy < BasePolicy
prevent :award_emoji prevent :award_emoji
prevent :read_note prevent :read_note
end end
rule { ~anonymous & can?(:read_epic) }.policy do
enable :create_todo
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Todos::Create do
include GraphqlHelpers
context 'with epics as target' do
before do
stub_licensed_features(epics: true)
end
it_behaves_like 'create todo mutation' do
let_it_be(:group) { create(:group, :private) }
let_it_be(:target) { create(:epic, group: group) }
end
end
end
...@@ -28,21 +28,21 @@ RSpec.describe EpicPolicy do ...@@ -28,21 +28,21 @@ RSpec.describe EpicPolicy do
shared_examples 'can only read epics' do shared_examples 'can only read epics' do
it do it do
is_expected.to be_allowed(:read_epic, :read_epic_iid, :read_note) is_expected.to be_allowed(:read_epic, :read_epic_iid, :read_note, :create_todo)
is_expected.to be_disallowed(:update_epic, :destroy_epic, :admin_epic, :create_epic) is_expected.to be_disallowed(:update_epic, :destroy_epic, :admin_epic, :create_epic)
end end
end end
shared_examples 'can manage epics' do shared_examples 'can manage epics' do
it { is_expected.to be_allowed(:read_epic, :read_epic_iid, :read_note, :update_epic, :admin_epic, :create_epic) } it { is_expected.to be_allowed(:read_epic, :read_epic_iid, :read_note, :update_epic, :admin_epic, :create_epic, :create_todo) }
end end
shared_examples 'all epic permissions disabled' do shared_examples 'all epic permissions disabled' do
it { is_expected.to be_disallowed(:read_epic, :read_epic_iid, :update_epic, :destroy_epic, :admin_epic, :create_epic, :create_note, :award_emoji, :read_note) } it { is_expected.to be_disallowed(:read_epic, :read_epic_iid, :update_epic, :destroy_epic, :admin_epic, :create_epic, :create_note, :award_emoji, :read_note, :create_todo) }
end end
shared_examples 'all reporter epic permissions enabled' do shared_examples 'all reporter epic permissions enabled' do
it { is_expected.to be_allowed(:read_epic, :read_epic_iid, :update_epic, :admin_epic, :create_epic, :create_note, :award_emoji, :read_note) } it { is_expected.to be_allowed(:read_epic, :read_epic_iid, :update_epic, :admin_epic, :create_epic, :create_note, :award_emoji, :read_note, :create_todo) }
end end
shared_examples 'group member permissions' do shared_examples 'group member permissions' do
...@@ -153,7 +153,8 @@ RSpec.describe EpicPolicy do ...@@ -153,7 +153,8 @@ RSpec.describe EpicPolicy do
context 'anonymous user' do context 'anonymous user' do
let(:user) { nil } let(:user) { nil }
it_behaves_like 'can only read epics' it { is_expected.to be_allowed(:read_epic, :read_epic_iid, :read_note) }
it { is_expected.to be_disallowed(:create_todo) }
it_behaves_like 'cannot comment on epics' it_behaves_like 'cannot comment on epics'
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Todos::Create do
include GraphqlHelpers
include DesignManagementTestHelpers
describe '#resolve' do
context 'when target does not support todos' do
it 'raises error' do
current_user = create(:user)
mutation = described_class.new(object: nil, context: { current_user: current_user }, field: nil)
target = create(:milestone)
expect { mutation.resolve(target_id: global_id_of(target)) }
.to raise_error(GraphQL::CoercionError)
end
end
context 'with issue as target' do
it_behaves_like 'create todo mutation' do
let_it_be(:target) { create(:issue) }
end
end
context 'with merge request as target' do
it_behaves_like 'create todo mutation' do
let_it_be(:target) { create(:merge_request) }
end
end
context 'with design as target' do
before do
enable_design_management
end
it_behaves_like 'create todo mutation' do
let_it_be(:target) { create(:design) }
end
end
end
end
...@@ -139,8 +139,13 @@ RSpec.describe IssuePolicy do ...@@ -139,8 +139,13 @@ RSpec.describe IssuePolicy do
create(:project_group_link, group: group, project: project) create(:project_group_link, group: group, project: project)
end end
it 'does not allow guest to create todos' do
expect(permissions(nil, issue)).to be_allowed(:read_issue)
expect(permissions(nil, issue)).to be_disallowed(:create_todo)
end
it 'allows guests to read issues' do it 'allows guests to read issues' do
expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid) expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid, :create_todo)
expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue) expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue)
expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid) expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid)
......
...@@ -24,6 +24,7 @@ RSpec.describe MergeRequestPolicy do ...@@ -24,6 +24,7 @@ RSpec.describe MergeRequestPolicy do
mr_perms = %i[create_merge_request_in mr_perms = %i[create_merge_request_in
create_merge_request_from create_merge_request_from
read_merge_request read_merge_request
create_todo
approve_merge_request approve_merge_request
create_note].freeze create_note].freeze
...@@ -47,6 +48,18 @@ RSpec.describe MergeRequestPolicy do ...@@ -47,6 +48,18 @@ RSpec.describe MergeRequestPolicy do
end end
end end
context 'when merge request is public' do
context 'and user is anonymous' do
let(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: author) }
subject { permissions(nil, merge_request) }
it do
is_expected.to be_disallowed(:create_todo)
end
end
end
context 'when merge requests have been disabled' do context 'when merge requests have been disabled' do
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: author) } let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: author) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Create a todo' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:target) { create(:issue) }
let(:input) do
{
'targetId' => target.to_global_id.to_s
}
end
let(:mutation) { graphql_mutation(:todoCreate, input) }
let(:mutation_response) { graphql_mutation_response(:todoCreate) }
context 'the user is not allowed to create todo' do
it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to create todo' do
before do
target.project.add_guest(current_user)
end
it 'creates todo' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['todo']['body']).to eq(target.title)
expect(mutation_response['todo']['state']).to eq('pending')
end
end
end
# frozen_string_literal: true
RSpec.shared_examples 'create todo mutation' do
let_it_be(:current_user) { create(:user) }
let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
context 'when user does not have permission to create todo' do
it 'raises error' do
expect { mutation.resolve(target_id: global_id_of(target)) }
.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when user has permission to create todo' do
it 'creates a todo' do
target.resource_parent.add_reporter(current_user)
result = mutation.resolve(target_id: global_id_of(target))
expect(result[:todo]).to be_valid
expect(result[:todo].target).to eq(target)
expect(result[:todo].state).to eq('pending')
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