Commit 960dd310 authored by Sean Arnold's avatar Sean Arnold

Merge branch '344060-update-timeline-events-graphql-mutation' into 'master'

Add Graphql mutation to update timeline event

See merge request gitlab-org/gitlab!79016
parents 31834bbc d87747c6
...@@ -4356,6 +4356,27 @@ Input type: `TimelineEventDestroyInput` ...@@ -4356,6 +4356,27 @@ Input type: `TimelineEventDestroyInput`
| <a id="mutationtimelineeventdestroyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationtimelineeventdestroyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationtimelineeventdestroytimelineevent"></a>`timelineEvent` | [`TimelineEventType`](#timelineeventtype) | Timeline event. | | <a id="mutationtimelineeventdestroytimelineevent"></a>`timelineEvent` | [`TimelineEventType`](#timelineeventtype) | Timeline event. |
### `Mutation.timelineEventUpdate`
Input type: `TimelineEventUpdateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationtimelineeventupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationtimelineeventupdateid"></a>`id` | [`IncidentManagementTimelineEventID!`](#incidentmanagementtimelineeventid) | ID of the timeline event to update. |
| <a id="mutationtimelineeventupdatenote"></a>`note` | [`String`](#string) | Text note of the timeline event. |
| <a id="mutationtimelineeventupdateoccurredat"></a>`occurredAt` | [`Time`](#time) | Timestamp when the event occurred. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationtimelineeventupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationtimelineeventupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationtimelineeventupdatetimelineevent"></a>`timelineEvent` | [`TimelineEventType`](#timelineeventtype) | Timeline event. |
### `Mutation.todoCreate` ### `Mutation.todoCreate`
Input type: `TodoCreateInput` Input type: `TodoCreateInput`
...@@ -79,6 +79,7 @@ module EE ...@@ -79,6 +79,7 @@ module EE
mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Create mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Create
mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Update mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Update
mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Destroy mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Destroy
mount_mutation ::Mutations::IncidentManagement::TimelineEvent::Update
mount_mutation ::Mutations::IncidentManagement::TimelineEvent::Destroy mount_mutation ::Mutations::IncidentManagement::TimelineEvent::Destroy
mount_mutation ::Mutations::AppSec::Fuzzing::API::CiConfiguration::Create mount_mutation ::Mutations::AppSec::Fuzzing::API::CiConfiguration::Create
mount_mutation ::Mutations::AppSec::Fuzzing::Coverage::Corpus::Create, feature_flag: :corpus_management mount_mutation ::Mutations::AppSec::Fuzzing::Coverage::Corpus::Create, feature_flag: :corpus_management
......
# frozen_string_literal: true
module Mutations
module IncidentManagement
module TimelineEvent
class Update < Base
graphql_name 'TimelineEventUpdate'
argument :id, ::Types::GlobalIDType[::IncidentManagement::TimelineEvent],
required: true,
description: 'ID of the timeline event to update.'
argument :note, GraphQL::Types::String,
required: false,
description: 'Text note of the timeline event.'
argument :occurred_at, Types::TimeType,
required: false,
description: 'Timestamp when the event occurred.'
def resolve(id:, **args)
timeline_event = authorized_find!(id: id)
response ::IncidentManagement::TimelineEvents::UpdateService.new(
timeline_event,
current_user,
args
).execute
end
end
end
end
end
...@@ -2,8 +2,13 @@ ...@@ -2,8 +2,13 @@
module IncidentManagement module IncidentManagement
class TimelineEvent < ApplicationRecord class TimelineEvent < ApplicationRecord
include CacheMarkdownField
self.table_name = 'incident_management_timeline_events' self.table_name = 'incident_management_timeline_events'
# TODO: Implement custom pipeline https://gitlab.com/gitlab-org/gitlab/-/issues/351214
cache_markdown_field :note, pipeline: :note, issuable_reference_expansion_enabled: true
belongs_to :project belongs_to :project
belongs_to :author, class_name: 'User', foreign_key: :author_id belongs_to :author, class_name: 'User', foreign_key: :author_id
belongs_to :incident, class_name: 'Issue', foreign_key: :issue_id, inverse_of: :incident_management_timeline_events belongs_to :incident, class_name: 'Issue', foreign_key: :issue_id, inverse_of: :incident_management_timeline_events
......
# frozen_string_literal: true
module IncidentManagement
module TimelineEvents
# @param timeline_event [IncidentManagement::TimelineEvent]
# @param user [User]
# @param params [Hash]
# @option params [string] note
# @option params [datetime] occurred_at
class UpdateService < TimelineEvents::BaseService
def initialize(timeline_event, user, params)
@timeline_event = timeline_event
@incident = timeline_event.incident
@user = user
@note = params[:note]
@occurred_at = params[:occurred_at]
end
def execute
return error_no_permissions unless allowed?
if timeline_event.update(update_params)
success(timeline_event)
else
error_in_save(timeline_event)
end
end
private
attr_reader :timeline_event, :incident, :user, :note, :occurred_at
def update_params
{ updated_by_user: user, note: note.presence, occurred_at: occurred_at.presence }.compact
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::IncidentManagement::TimelineEvent::Update do
let_it_be(:developer) { create(:user) }
let_it_be(:reporter) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:incident) { create(:incident, project: project) }
let_it_be_with_reload(:timeline_event) { create(:incident_management_timeline_event, project: project, incident: incident) }
let(:args) do
{
id: timeline_event_id,
note: note,
occurred_at: occurred_at
}
end
let(:note) { 'Updated Note' }
let(:timeline_event_id) { GitlabSchema.id_from_object(timeline_event).to_s }
let(:occurred_at) { 1.minute.ago }
before do
project.add_developer(developer)
project.add_reporter(reporter)
stub_licensed_features(incident_timeline_events: true)
end
describe '#resolve' do
let(:current_user) { developer }
subject(:resolve) { mutation_for(current_user).resolve(**args) }
shared_examples 'failed update with a top-level access error' do |error|
specify do
expect { resolve }.to raise_error(
Gitlab::Graphql::Errors::ResourceNotAvailable,
error || Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR
)
end
end
context 'when licenced feature is available' do
context 'when user has permissions to update the timeline event' do
context 'when timeline event exists' do
it 'updates the timeline event' do
expect { resolve }.to change { timeline_event.reload.note }.to(note)
.and change { timeline_event.reload.occurred_at.to_s }.to(occurred_at.to_s)
end
it 'returns updated timeline event' do
expect(resolve).to eq(
timeline_event: timeline_event.reload,
errors: []
)
end
context 'when there is a validation error' do
let(:occurred_at) { 'invalid date' }
it 'does not update the timeline event' do
expect { resolve }.not_to change { timeline_event.reload.updated_at }
end
it 'responds with error' do
expect(resolve).to eq(
timeline_event: nil,
errors: ["Occurred at can't be blank"]
)
end
end
end
context 'when timeline event cannot be found' do
let(:timeline_event_id) do
Gitlab::GlobalId.build(
nil,
model_name: ::IncidentManagement::TimelineEvent.name,
id: non_existing_record_id
).to_s
end
it_behaves_like 'failed update with a top-level access error'
end
end
context 'when user does not have permissions to update the timeline event' do
let(:current_user) { reporter }
it_behaves_like 'failed update with a top-level access error'
end
end
context 'when licensed feature is not available' do
before do
stub_licensed_features(incident_timeline_events: false)
end
it_behaves_like 'failed update with a top-level access error', 'Timeline events are not supported for this project'
end
end
private
def mutation_for(user)
described_class.new(object: nil, context: { current_user: user }, field: nil)
end
end
...@@ -4,7 +4,8 @@ require 'spec_helper' ...@@ -4,7 +4,8 @@ require 'spec_helper'
RSpec.describe IncidentManagement::TimelineEvent do RSpec.describe IncidentManagement::TimelineEvent do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:timeline_event) { create(:incident_management_timeline_event, project: project) } let_it_be(:incident) { create(:incident, project: project) }
let_it_be(:timeline_event) { create(:incident_management_timeline_event, project: project, incident: incident) }
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
...@@ -38,4 +39,35 @@ RSpec.describe IncidentManagement::TimelineEvent do ...@@ -38,4 +39,35 @@ RSpec.describe IncidentManagement::TimelineEvent do
is_expected.to eq([occurred_3mins_ago, occurred_2mins_ago, timeline_event]) is_expected.to eq([occurred_3mins_ago, occurred_2mins_ago, timeline_event])
end end
end end
describe '#cache_markdown_field' do
let(:note) { '<p>some html</p>' }
let(:expected_note_html) { '<p dir="auto">some html</p>' }
before do
allow(Banzai::Renderer).to receive(:cacheless_render_field).and_call_original
end
context 'on create' do
let(:timeline_event) { build(:incident_management_timeline_event, project: project, incident: incident, note: note) }
it 'updates note_html', :aggregate_failures do
expect(Banzai::Renderer).to receive(:cacheless_render_field)
.with(timeline_event, :note, { skip_project_check: false })
expect { timeline_event.save! }.to change { timeline_event.note_html }.to(expected_note_html)
end
end
context 'on update' do
let(:timeline_event) { create(:incident_management_timeline_event, project: project, incident: incident) }
it 'updates note_html', :aggregate_failures do
expect(Banzai::Renderer).to receive(:cacheless_render_field)
.with(timeline_event, :note, { skip_project_check: false })
expect { timeline_event.update!(note: note) }.to change { timeline_event.note_html }.to(expected_note_html)
end
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Updating an incident timeline event' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:incident) { create(:incident, project: project) }
let_it_be_with_reload(:timeline_event) { create(:incident_management_timeline_event, incident: incident, project: project) }
let(:occurred_at) { 1.minute.ago.iso8601 }
let(:variables) do
{
id: timeline_event.to_global_id.to_s,
note: 'Updated note',
occurred_at: occurred_at
}
end
let(:mutation) do
graphql_mutation(:timeline_event_update, variables) do
<<~QL
clientMutationId
errors
timelineEvent {
id
author { id username }
updatedByUser { id username }
incident { id title }
note
noteHtml
occurredAt
createdAt
updatedAt
}
QL
end
end
let(:mutation_response) { graphql_mutation_response(:timeline_event_update) }
before do
stub_licensed_features(incident_timeline_events: true)
project.add_developer(user)
end
it 'updates the timeline event', :aggregate_failures do
post_graphql_mutation(mutation, current_user: user)
timeline_event_response = mutation_response['timelineEvent']
timeline_event.reload
expect(response).to have_gitlab_http_status(:success)
expect(timeline_event_response).to include(
'id' => timeline_event.to_global_id.to_s,
'author' => {
'id' => timeline_event.author.to_global_id.to_s,
'username' => timeline_event.author.username
},
'updatedByUser' => {
'id' => user.to_global_id.to_s,
'username' => user.username
},
'incident' => {
'id' => incident.to_global_id.to_s,
'title' => incident.title
},
'note' => 'Updated note',
'noteHtml' => timeline_event.note_html,
'occurredAt' => occurred_at,
'createdAt' => timeline_event.created_at.iso8601,
'updatedAt' => timeline_event.updated_at.iso8601
)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IncidentManagement::TimelineEvents::UpdateService do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:incident) { create(:incident, project: project) }
let!(:timeline_event) { create(:incident_management_timeline_event, project: project, incident: incident) }
let(:occurred_at) { 1.minute.ago }
let(:params) { { note: 'Updated note', occurred_at: occurred_at } }
before do
stub_licensed_features(incident_timeline_events: true)
end
describe '#execute' do
shared_examples 'successful response' do
it 'responds with success', :aggregate_failures do
expect(execute).to be_success
expect(execute.payload).to eq(timeline_event: timeline_event.reload)
end
end
shared_examples 'error response' do |message|
it 'has an informative message' do
expect(execute).to be_error
expect(execute.message).to eq(message)
end
end
subject(:execute) { described_class.new(timeline_event, user, params).execute }
context 'when user has permissions' do
before do
project.add_developer(user)
end
it_behaves_like 'successful response'
it 'updates attributes' do
expect { execute }.to change { timeline_event.note }.to(params[:note])
.and change { timeline_event.occurred_at }.to(params[:occurred_at])
end
context 'when note is nil' do
let(:params) { { occurred_at: occurred_at } }
it_behaves_like 'successful response'
it 'does not update the note' do
expect { execute }.not_to change { timeline_event.reload.note }
end
it 'updates occurred_at' do
expect { execute }.to change { timeline_event.occurred_at }.to(params[:occurred_at])
end
end
context 'when note is blank' do
let(:params) { { note: '', occurred_at: occurred_at } }
it_behaves_like 'successful response'
it 'does not update the note' do
expect { execute }.not_to change { timeline_event.reload.note }
end
it 'updates occurred_at' do
expect { execute }.to change { timeline_event.occurred_at }.to(params[:occurred_at])
end
end
context 'when occurred_at is nil' do
let(:params) { { note: 'Updated note' } }
it_behaves_like 'successful response'
it 'updates the note' do
expect { execute }.to change { timeline_event.note }.to(params[:note])
end
it 'does not update occurred_at' do
expect { execute }.not_to change { timeline_event.reload.occurred_at }
end
end
end
context 'when user does not have permissions' do
before do
project.add_reporter(user)
end
it_behaves_like 'error response', 'You have insufficient permissions to manage timeline events for this incident'
end
context 'when licensed feature is not available' do
before do
stub_licensed_features(incident_timeline_events: false)
end
it_behaves_like 'error response', 'You have insufficient permissions to manage timeline events for this incident'
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