diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index 7c2a7878c58..090d5473d1d 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -3,10 +3,10 @@ import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; import { fetchPolicies } from '~/lib/graphql'; +import notesEventHub from '~/notes/event_hub'; import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; import { displayAndLogError } from './utils'; import { timelineTabI18n } from './constants'; - import CreateTimelineEvent from './create_timeline_event.vue'; import IncidentTimelineEventsList from './timeline_events_list.vue'; @@ -56,7 +56,16 @@ export default { return !this.timelineEventLoading && !this.hasTimelineEvents; }, }, + mounted() { + notesEventHub.$on('comment-promoted-to-timeline-event', this.refreshTimelineEvents); + }, + destroyed() { + notesEventHub.$off('comment-promoted-to-timeline-event', this.refreshTimelineEvents); + }, methods: { + refreshTimelineEvents() { + this.$apollo.queries.timelineEvents.refetch(); + }, hideEventForm() { this.isEventFormVisible = false; }, diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index c7f293a219a..9806f8e5dc2 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,6 +1,6 @@ + diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index a5f459c8910..88f438975f6 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -13,6 +13,7 @@ export const MERGED = 'merged'; export const ISSUE_NOTEABLE_TYPE = 'Issue'; export const EPIC_NOTEABLE_TYPE = 'Epic'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest'; +export const INCIDENT_NOTEABLE_TYPE = 'INCIDENT'; // TODO: check if value can be converted to `Incident` export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; export const DESCRIPTION_TYPE = 'changed the description'; @@ -31,6 +32,7 @@ export const NOTEABLE_TYPE_MAPPING = { Issue: ISSUE_NOTEABLE_TYPE, MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE, Epic: EPIC_NOTEABLE_TYPE, + Incident: INCIDENT_NOTEABLE_TYPE, }; export const DISCUSSION_FILTER_TYPES = { diff --git a/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql b/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql new file mode 100644 index 00000000000..c9df9cfd6d3 --- /dev/null +++ b/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql @@ -0,0 +1,8 @@ +mutation PromoteTimelineEvent($input: TimelineEventPromoteFromNoteInput!) { + timelineEventPromoteFromNote(input: $input) { + timelineEvent { + id + } + errors + } +} diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 27e54a1ea69..054a5bd36e2 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import NotesApp from './components/notes_app.vue'; import initDiscussionFilters from './discussion_filters'; import { store } from './stores'; @@ -39,6 +40,7 @@ export default () => { username: parsedUserData.username, avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, path: parsedUserData.path, + can_add_timeline_events: parseBoolean(notesDataset.canAddTimelineEvents), }; } diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 82417c9134b..fcef26d720c 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -6,6 +6,7 @@ import createFlash from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; +import toast from '~/vue_shared/plugins/global_toast'; import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; @@ -18,6 +19,12 @@ import sidebarTimeTrackingEventHub from '~/sidebar/event_hub'; import TaskList from '~/task_list'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import SidebarStore from '~/sidebar/stores/sidebar_store'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_NOTE } from '~/graphql_shared/constants'; +import notesEventHub from '../event_hub'; + +import promoteTimelineEvent from '../graphql/promote_timeline_event.mutation.graphql'; + import * as constants from '../constants'; import * as types from './mutation_types'; import * as utils from './utils'; @@ -226,6 +233,54 @@ export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes) }); }; +export const promoteCommentToTimelineEvent = ( + { commit }, + { noteId, addError, addGenericError }, +) => { + commit(types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, true); // Set loading state + return utils.gqClient + .mutate({ + mutation: promoteTimelineEvent, + variables: { + input: { + noteId: convertToGraphQLId(TYPE_NOTE, noteId), + }, + }, + }) + .then(({ data = {} }) => { + const errors = data.timelineEventPromoteFromNote?.errors; + if (errors.length) { + const errorMessage = sprintf(addError, { + error: errors.join('. '), + }); + throw new Error(errorMessage); + } else { + notesEventHub.$emit('comment-promoted-to-timeline-event'); + toast(__('Comment added to the timeline.')); + } + }) + .catch((error) => { + const message = error.message || addGenericError; + + let captureError = false; + let errorObj = null; + + if (message === addGenericError) { + captureError = true; + errorObj = error; + } + + createFlash({ + message, + captureError, + error: errorObj, + }); + }) + .finally(() => { + commit(types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, false); // Revert loading state + }); +}; + export const replyToDiscussion = ( { commit, state, getters, dispatch }, { endpoint, data: reply }, diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 1fe82d96435..6876220f75c 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -93,6 +93,13 @@ export const getUserDataByProp = (state) => (prop) => state.userData && state.us export const descriptionVersions = (state) => state.descriptionVersions; +export const canUserAddIncidentTimelineEvents = (state) => { + return ( + state.userData.can_add_timeline_events && + state.noteableData.type === constants.NOTEABLE_TYPE_MAPPING.Incident + ); +}; + export const notesById = (state) => state.discussions.reduce((acc, note) => { note.notes.every((n) => Object.assign(acc, { [n.id]: n })); diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index f779aad5679..7ba1f470b05 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -30,6 +30,7 @@ export default () => ({ isNotesFetched: false, isLoading: true, isLoadingDescriptionVersion: false, + isPromoteCommentToTimelineEventInProgress: false, // holds endpoints and permissions provided through haml notesData: { diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index e28a7bc5cdd..42df6bc0980 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -57,3 +57,6 @@ export const RECEIVE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DESCRIPTION_VERSION_ER export const REQUEST_DELETE_DESCRIPTION_VERSION = 'REQUEST_DELETE_DESCRIPTION_VERSION'; export const RECEIVE_DELETE_DESCRIPTION_VERSION = 'RECEIVE_DELETE_DESCRIPTION_VERSION'; export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR'; + +// Incidents +export const SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS = 'SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 0823eacf1b7..83c15c12eac 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -425,4 +425,7 @@ export default { [types.SET_DONE_FETCHING_BATCH_DISCUSSIONS](state, value) { state.doneFetchingBatchDiscussions = value; }, + [types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS](state, value) { + state.isPromoteCommentToTimelineEventInProgress = value; + }, }; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index cc930d67fa4..30f57f506a6 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -81,6 +81,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { protobuf: 'protobuf', puppet: 'puppet', python: 'python', + python3: 'python', q: 'q', qml: 'qml', r: 'r', diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index f471db24889..9c6c12eac7d 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -42,7 +42,7 @@ export default { return { languageDefinition: null, content: this.blob.rawTextBlob, - language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language], + language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()], hljs: null, firstChunk: null, chunks: {}, @@ -62,7 +62,7 @@ export default { const supportedLanguages = Object.keys(languageLoader); return ( !supportedLanguages.includes(this.language) && - !supportedLanguages.includes(this.blob.language) + !supportedLanguages.includes(this.blob.language?.toLowerCase()) ); }, }, diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 16b795ee3c9..11b652cc818 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -7,4 +7,5 @@ noteable_data: serialize_issuable(@issue, with_blocking_issues: true), noteable_type: 'Issue', target_type: 'issue', - current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json } } + current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json, + can_add_timeline_events: "#{can?(current_user, :admin_incident_management_timeline_event, @issue)}" } } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e30601d3011..2244e016228 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2227,6 +2227,9 @@ msgstr "" msgid "Add comment to design" msgstr "" +msgid "Add comment to incident timeline" +msgstr "" + msgid "Add comment..." msgstr "" @@ -9451,6 +9454,9 @@ msgstr "" msgid "Comment '%{label}' position" msgstr "" +msgid "Comment added to the timeline." +msgstr "" + msgid "Comment form position" msgstr "" @@ -15253,6 +15259,9 @@ msgstr "" msgid "Error parsing CSV file. Please make sure it has" msgstr "" +msgid "Error promoting the note to timeline event: %{error}" +msgstr "" + msgid "Error rendering Markdown preview" msgstr "" @@ -37002,6 +37011,9 @@ msgstr "" msgid "Something went wrong while promoting the issue to an epic. Please try again." msgstr "" +msgid "Something went wrong while promoting the note to timeline event." +msgstr "" + msgid "Something went wrong while reopening a requirement." msgstr "" diff --git a/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js b/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js new file mode 100644 index 00000000000..658e844a9b1 --- /dev/null +++ b/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js @@ -0,0 +1,35 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import TimelineEventButton from '~/notes/components/note_actions/timeline_event_button.vue'; + +const emitData = { + noteId: '1', + addError: 'Error promoting the note to timeline event: %{error}', + addGenericError: 'Something went wrong while promoting the note to timeline event.', +}; + +describe('NoteTimelineEventButton', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(TimelineEventButton, { + propsData: { + noteId: '1', + isPromotionInProgress: true, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findTimelineButton = () => wrapper.findComponent(GlButton); + + it('emits click-promote-comment-to-event', async () => { + findTimelineButton().vm.$emit('click'); + + expect(wrapper.emitted('click-promote-comment-to-event')).toEqual([[emitData]]); + expect(findTimelineButton().props('disabled')).toEqual(true); + }); +}); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 02b27eca196..989dd74b6d0 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -4,6 +4,7 @@ import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; import Api from '~/api'; import createFlash from '~/flash'; +import toast from '~/vue_shared/plugins/global_toast'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import * as notesConstants from '~/notes/constants'; @@ -14,7 +15,9 @@ import mutations from '~/notes/stores/mutations'; import * as utils from '~/notes/stores/utils'; import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; +import promoteTimelineEvent from '~/notes/graphql/promote_timeline_event.mutation.graphql'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; +import notesEventHub from '~/notes/event_hub'; import waitForPromises from 'helpers/wait_for_promises'; import { resetStore } from '../helpers'; import { @@ -38,6 +41,8 @@ jest.mock('~/flash', () => { return flash; }); +jest.mock('~/vue_shared/plugins/global_toast'); + describe('Actions Notes Store', () => { let commit; let dispatch; @@ -1324,6 +1329,102 @@ describe('Actions Notes Store', () => { }); }); + describe('promoteCommentToTimelineEvent', () => { + const actionArgs = { + noteId: '1', + addError: 'addError: Create error', + addGenericError: 'addGenericError', + }; + const commitSpy = jest.fn(); + + describe('for successful request', () => { + const timelineEventSuccessResponse = { + data: { + timelineEventPromoteFromNote: { + timelineEvent: { + id: 'gid://gitlab/IncidentManagement::TimelineEvent/19', + }, + errors: [], + }, + }, + }; + + beforeEach(() => { + jest.spyOn(utils.gqClient, 'mutate').mockResolvedValue(timelineEventSuccessResponse); + }); + + it('calls gqClient mutation with the correct values', () => { + actions.promoteCommentToTimelineEvent({ commit: () => {} }, actionArgs); + + expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1); + expect(utils.gqClient.mutate).toHaveBeenCalledWith({ + mutation: promoteTimelineEvent, + variables: { + input: { + noteId: 'gid://gitlab/Note/1', + }, + }, + }); + }); + + it('returns success response', () => { + jest.spyOn(notesEventHub, '$emit').mockImplementation(() => {}); + + return actions.promoteCommentToTimelineEvent({ commit: commitSpy }, actionArgs).then(() => { + expect(notesEventHub.$emit).toHaveBeenLastCalledWith( + 'comment-promoted-to-timeline-event', + ); + expect(toast).toHaveBeenCalledWith('Comment added to the timeline.'); + expect(commitSpy).toHaveBeenCalledWith( + mutationTypes.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, + false, + ); + }); + }); + }); + + describe('for failing request', () => { + const errorResponse = { + data: { + timelineEventPromoteFromNote: { + timelineEvent: null, + errors: ['Create error'], + }, + }, + }; + + it.each` + mockReject | message | captureError | error + ${true} | ${'addGenericError'} | ${true} | ${new Error()} + ${false} | ${'addError: Create error'} | ${false} | ${null} + `( + 'should show an error when submission fails', + ({ mockReject, message, captureError, error }) => { + const expectedAlertArgs = { + captureError, + error, + message, + }; + if (mockReject) { + jest.spyOn(utils.gqClient, 'mutate').mockRejectedValueOnce(new Error()); + } else { + jest.spyOn(utils.gqClient, 'mutate').mockResolvedValue(errorResponse); + } + + return actions + .promoteCommentToTimelineEvent({ commit: commitSpy }, actionArgs) + .then(() => { + expect(createFlash).toHaveBeenCalledWith(expectedAlertArgs); + expect(commitSpy).toHaveBeenCalledWith( + mutationTypes.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, + false, + ); + }); + }, + ); + }); + }); + describe('setFetchingState', () => { it('commits SET_NOTES_FETCHING_STATE', () => { return testAction( diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index 4fbc907a813..f2e3fa8d433 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -110,6 +110,13 @@ describe('Source Viewer component', () => { expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default); }); + it('correctly maps languages starting with uppercase', async () => { + await createComponent({ language: 'Python3' }); + const languageDefinition = await import(`highlight.js/lib/languages/python`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith('python', languageDefinition.default); + }); + it('highlights the first chunk', () => { expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); });