Commit b9a84dd6 authored by Alan (Maciej) Paruszewski's avatar Alan (Maciej) Paruszewski Committed by Marc Shaw

Add GraphQL API to Create Saved Reply

parent 6b93e111
# frozen_string_literal: true
module Mutations
module SavedReplies
class Base < BaseMutation
field :saved_reply, Types::SavedReplyType,
null: true,
description: 'Updated saved reply.'
private
def present_result(result)
if result.success?
{
saved_reply: result[:saved_reply],
errors: []
}
else
{
saved_reply: nil,
errors: result.message
}
end
end
def feature_enabled?
Feature.enabled?(:saved_replies, current_user, default_enabled: :yaml)
end
end
end
end
# frozen_string_literal: true
module Mutations
module SavedReplies
class Create < Base
graphql_name 'SavedReplyCreate'
authorize :create_saved_replies
argument :name, GraphQL::Types::String,
required: true,
description: copy_field_description(Types::SavedReplyType, :name)
argument :content, GraphQL::Types::String,
required: true,
description: copy_field_description(Types::SavedReplyType, :content)
def resolve(name:, content:)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled?
result = ::Users::SavedReplies::CreateService.new(current_user: current_user, name: name, content: content).execute
present_result(result)
end
end
end
end
......@@ -129,6 +129,7 @@ module Types
mount_mutation Mutations::WorkItems::CreateFromTask
mount_mutation Mutations::WorkItems::Delete
mount_mutation Mutations::WorkItems::Update
mount_mutation Mutations::SavedReplies::Create
end
end
......
# frozen_string_literal: true
module Types
class SavedReplyType < BaseObject
graphql_name 'SavedReply'
authorize :read_saved_replies
field :id, Types::GlobalIDType[::Users::SavedReply],
null: false,
description: 'Global ID of the saved reply.'
field :content, GraphQL::Types::String,
null: false,
description: 'Content of the saved reply.'
field :name, GraphQL::Types::String,
null: false,
description: 'Name of the saved reply.'
end
end
......@@ -115,6 +115,10 @@ module Types
extras: [:lookahead],
complexity: 5,
resolver: ::Resolvers::TimelogResolver
field :saved_replies,
Types::SavedReplyType.connection_type,
null: true,
description: 'Saved replies authored by the user.'
field :gitpod_enabled, GraphQL::Types::Boolean, null: true,
description: 'Whether Gitpod is enabled at the user level.'
......
......@@ -23,9 +23,11 @@ class UserPolicy < BasePolicy
enable :destroy_user
enable :update_user
enable :update_user_status
enable :create_saved_replies
enable :read_user_personal_access_tokens
enable :read_group_count
enable :read_user_groups
enable :read_saved_replies
end
rule { default }.enable :read_user_profile
......
# frozen_string_literal: true
module Users
class SavedReplyPolicy < BasePolicy
delegate { @subject.user }
end
end
......@@ -19,6 +19,14 @@ class UserPresenter < Gitlab::View::Presenter::Delegated
profile_path(user: { gitpod_enabled: true }) if application_gitpod_enabled?
end
delegator_override :saved_replies
def saved_replies
return ::Users::SavedReply.none unless Feature.enabled?(:saved_replies, current_user, default_enabled: :yaml)
return ::Users::SavedReply.none unless current_user.can?(:read_saved_replies, user)
user.saved_replies
end
private
def can?(*args)
......
# frozen_string_literal: true
module Users
module SavedReplies
class CreateService
def initialize(current_user:, name:, content:)
@current_user = current_user
@name = name
@content = content
end
def execute
saved_reply = saved_replies.build(name: name, content: content)
if saved_reply.save
ServiceResponse.success(payload: { saved_reply: saved_reply })
else
ServiceResponse.error(message: saved_reply.errors.full_messages)
end
end
private
attr_reader :current_user, :name, :content
delegate :saved_replies, to: :current_user
end
end
end
---
name: saved_replies
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80811
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/352956
milestone: '14.9'
type: development
group: group::project management
default_enabled: false
......@@ -4239,6 +4239,26 @@ Input type: `RunnersRegistrationTokenResetInput`
| <a id="mutationrunnersregistrationtokenreseterrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationrunnersregistrationtokenresettoken"></a>`token` | [`String`](#string) | Runner token after mutation. |
### `Mutation.savedReplyCreate`
Input type: `SavedReplyCreateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationsavedreplycreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationsavedreplycreatecontent"></a>`content` | [`String!`](#string) | Content of the saved reply. |
| <a id="mutationsavedreplycreatename"></a>`name` | [`String!`](#string) | Name of the saved reply. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationsavedreplycreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationsavedreplycreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationsavedreplycreatesavedreply"></a>`savedReply` | [`SavedReply`](#savedreply) | Updated saved reply. |
### `Mutation.scanExecutionPolicyCommit`
Commits the `policy_yaml` content to the assigned security policy project for the given project(`project_path`).
......@@ -7782,6 +7802,29 @@ The edge type for [`SastCiConfigurationOptionsEntity`](#sastciconfigurationoptio
| <a id="sastciconfigurationoptionsentityedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="sastciconfigurationoptionsentityedgenode"></a>`node` | [`SastCiConfigurationOptionsEntity`](#sastciconfigurationoptionsentity) | The item at the end of the edge. |
#### `SavedReplyConnection`
The connection type for [`SavedReply`](#savedreply).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="savedreplyconnectionedges"></a>`edges` | [`[SavedReplyEdge]`](#savedreplyedge) | A list of edges. |
| <a id="savedreplyconnectionnodes"></a>`nodes` | [`[SavedReply]`](#savedreply) | A list of nodes. |
| <a id="savedreplyconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
#### `SavedReplyEdge`
The edge type for [`SavedReply`](#savedreply).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="savedreplyedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="savedreplyedgenode"></a>`node` | [`SavedReply`](#savedreply) | The item at the end of the edge. |
#### `ScanConnection`
The connection type for [`Scan`](#scan).
......@@ -12496,6 +12539,7 @@ A user assigned to a merge request.
| <a id="mergerequestassigneeprofileenablegitpodpath"></a>`profileEnableGitpodPath` | [`String`](#string) | Web path to enable Gitpod for the user. |
| <a id="mergerequestassigneeprojectmemberships"></a>`projectMemberships` | [`ProjectMemberConnection`](#projectmemberconnection) | Project memberships of the user. (see [Connections](#connections)) |
| <a id="mergerequestassigneepublicemail"></a>`publicEmail` | [`String`](#string) | User's public email. |
| <a id="mergerequestassigneesavedreplies"></a>`savedReplies` | [`SavedReplyConnection`](#savedreplyconnection) | Saved replies authored by the user. (see [Connections](#connections)) |
| <a id="mergerequestassigneestate"></a>`state` | [`UserState!`](#userstate) | State of the user. |
| <a id="mergerequestassigneestatus"></a>`status` | [`UserStatus`](#userstatus) | User status. |
| <a id="mergerequestassigneeuserpermissions"></a>`userPermissions` | [`UserPermissions!`](#userpermissions) | Permissions for the current user on the resource. |
......@@ -12760,6 +12804,7 @@ A user assigned to a merge request as a reviewer.
| <a id="mergerequestreviewerprofileenablegitpodpath"></a>`profileEnableGitpodPath` | [`String`](#string) | Web path to enable Gitpod for the user. |
| <a id="mergerequestreviewerprojectmemberships"></a>`projectMemberships` | [`ProjectMemberConnection`](#projectmemberconnection) | Project memberships of the user. (see [Connections](#connections)) |
| <a id="mergerequestreviewerpublicemail"></a>`publicEmail` | [`String`](#string) | User's public email. |
| <a id="mergerequestreviewersavedreplies"></a>`savedReplies` | [`SavedReplyConnection`](#savedreplyconnection) | Saved replies authored by the user. (see [Connections](#connections)) |
| <a id="mergerequestreviewerstate"></a>`state` | [`UserState!`](#userstate) | State of the user. |
| <a id="mergerequestreviewerstatus"></a>`status` | [`UserStatus`](#userstatus) | User status. |
| <a id="mergerequestrevieweruserpermissions"></a>`userPermissions` | [`UserPermissions!`](#userpermissions) | Permissions for the current user on the resource. |
......@@ -15338,6 +15383,16 @@ Represents an entity for options in SAST CI configuration.
| <a id="sastciconfigurationoptionsentitylabel"></a>`label` | [`String`](#string) | Label of option entity. |
| <a id="sastciconfigurationoptionsentityvalue"></a>`value` | [`String`](#string) | Value of option entity. |
### `SavedReply`
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="savedreplycontent"></a>`content` | [`String!`](#string) | Content of the saved reply. |
| <a id="savedreplyid"></a>`id` | [`UsersSavedReplyID!`](#userssavedreplyid) | Global ID of the saved reply. |
| <a id="savedreplyname"></a>`name` | [`String!`](#string) | Name of the saved reply. |
### `Scan`
Represents the security scan information.
......@@ -16116,6 +16171,7 @@ Core represention of a GitLab user.
| <a id="usercoreprofileenablegitpodpath"></a>`profileEnableGitpodPath` | [`String`](#string) | Web path to enable Gitpod for the user. |
| <a id="usercoreprojectmemberships"></a>`projectMemberships` | [`ProjectMemberConnection`](#projectmemberconnection) | Project memberships of the user. (see [Connections](#connections)) |
| <a id="usercorepublicemail"></a>`publicEmail` | [`String`](#string) | User's public email. |
| <a id="usercoresavedreplies"></a>`savedReplies` | [`SavedReplyConnection`](#savedreplyconnection) | Saved replies authored by the user. (see [Connections](#connections)) |
| <a id="usercorestate"></a>`state` | [`UserState!`](#userstate) | State of the user. |
| <a id="usercorestatus"></a>`status` | [`UserStatus`](#userstatus) | User status. |
| <a id="usercoreuserpermissions"></a>`userPermissions` | [`UserPermissions!`](#userpermissions) | Permissions for the current user on the resource. |
......@@ -19189,6 +19245,12 @@ A `UserID` is a global ID. It is encoded as a string.
An example `UserID` is: `"gid://gitlab/User/1"`.
### `UsersSavedReplyID`
A `UsersSavedReplyID` is a global ID. It is encoded as a string.
An example `UsersSavedReplyID` is: `"gid://gitlab/Users::SavedReply/1"`.
### `VulnerabilitiesExternalIssueLinkID`
A `VulnerabilitiesExternalIssueLinkID` is a global ID. It is encoded as a string.
......@@ -19619,6 +19681,7 @@ Implementations:
| <a id="userprofileenablegitpodpath"></a>`profileEnableGitpodPath` | [`String`](#string) | Web path to enable Gitpod for the user. |
| <a id="userprojectmemberships"></a>`projectMemberships` | [`ProjectMemberConnection`](#projectmemberconnection) | Project memberships of the user. (see [Connections](#connections)) |
| <a id="userpublicemail"></a>`publicEmail` | [`String`](#string) | User's public email. |
| <a id="usersavedreplies"></a>`savedReplies` | [`SavedReplyConnection`](#savedreplyconnection) | Saved replies authored by the user. (see [Connections](#connections)) |
| <a id="userstate"></a>`state` | [`UserState!`](#userstate) | State of the user. |
| <a id="userstatus"></a>`status` | [`UserStatus`](#userstatus) | User status. |
| <a id="useruserpermissions"></a>`userPermissions` | [`UserPermissions!`](#userpermissions) | Permissions for the current user on the resource. |
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::SavedReplies::Create do
let_it_be(:current_user) { create(:user) }
let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
let(:mutation_arguments) { { name: 'save_reply_name', content: 'Save Reply Content' } }
describe '#resolve' do
subject(:resolve) do
mutation.resolve(**mutation_arguments)
end
context 'when feature is disabled' do
before do
stub_feature_flags(saved_replies: false)
end
it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled')
end
end
context 'when feature is enabled for current user' do
before do
stub_feature_flags(saved_replies: current_user)
end
context 'when service fails to create a new saved reply' do
let(:mutation_arguments) { { name: '', content: '' } }
it { expect(subject[:saved_reply]).to be_nil }
it { expect(subject[:errors]).to match_array(["Content can't be blank", "Name can't be blank", "Name can contain only lowercase letters, digits, '_' and '-'. Must start with a letter, and cannot end with '-' or '_'"]) }
end
context 'when service successfully creates a new saved reply' do
it { expect(subject[:saved_reply].name).to eq(mutation_arguments[:name]) }
it { expect(subject[:saved_reply].content).to eq(mutation_arguments[:content]) }
it { expect(subject[:errors]).to be_empty }
end
end
end
end
......@@ -37,6 +37,7 @@ RSpec.describe GitlabSchema.types['MergeRequestReviewer'] do
gitpodEnabled
preferencesGitpodPath
profileEnableGitpodPath
savedReplies
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['SavedReply'] do
specify { expect(described_class.graphql_name).to eq('SavedReply') }
it 'has all the required fields' do
expect(described_class).to have_graphql_fields(:id, :content, :name)
end
specify { expect(described_class).to require_graphql_authorizations(:read_saved_replies) }
end
......@@ -42,6 +42,7 @@ RSpec.describe GitlabSchema.types['User'] do
gitpodEnabled
preferencesGitpodPath
profileEnableGitpodPath
savedReplies
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
......@@ -5,7 +5,9 @@ require 'spec_helper'
RSpec.describe UserPresenter do
let_it_be(:user) { create(:user) }
subject(:presenter) { described_class.new(user) }
let(:current_user) { user }
subject(:presenter) { described_class.new(user, current_user: current_user) }
describe '#web_path' do
it { expect(presenter.web_path).to eq("/#{user.username}") }
......@@ -46,4 +48,33 @@ RSpec.describe UserPresenter do
end
end
end
describe '#saved_replies' do
let_it_be(:other_user) { create(:user) }
let_it_be(:saved_reply) { create(:saved_reply, user: user) }
context 'when feature is disabled' do
before do
stub_feature_flags(saved_replies: false)
end
it { expect(presenter.saved_replies).to eq(::Users::SavedReply.none) }
end
context 'when feature is enabled' do
before do
stub_feature_flags(saved_replies: current_user)
end
context 'when user has no permission to read saved replies' do
let(:current_user) { other_user }
it { expect(presenter.saved_replies).to eq(::Users::SavedReply.none) }
end
context 'when user has permission to read saved replies' do
it { expect(presenter.saved_replies).to eq([saved_reply]) }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Users::SavedReplies::CreateService do
describe '#execute' do
let_it_be(:current_user) { create(:user) }
let_it_be(:saved_reply) { create(:saved_reply, user: current_user) }
subject { described_class.new(current_user: current_user, name: name, content: content).execute }
context 'when create fails' do
let(:name) { saved_reply.name }
let(:content) { '' }
it { is_expected.not_to be_success }
it 'does not create new Saved Reply in database' do
expect { subject }.not_to change(::Users::SavedReply, :count)
end
it 'returns error messages' do
expect(subject.errors).to match_array(["Content can't be blank", "Name has already been taken"])
end
end
context 'when create succeeds' do
let(:name) { 'new_saved_reply_name' }
let(:content) { 'New content for Saved Reply' }
it { is_expected.to be_success }
it 'creates new Saved Reply in database' do
expect { subject }.to change(::Users::SavedReply, :count).by(1)
end
it 'returns new saved reply', :aggregate_failures do
expect(subject[:saved_reply]).to eq(::Users::SavedReply.last)
expect(subject[:saved_reply].name).to eq(name)
expect(subject[:saved_reply].content).to eq(content)
end
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