Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-08-29 06:09:31 +00:00
parent 5e5c529ef6
commit f2ba923aa7
18 changed files with 312 additions and 7 deletions

View File

@ -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;
},

View File

@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import createFlash from '~/flash';
@ -11,6 +11,7 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { splitCamelCase } from '~/lib/utils/text_utility';
import ReplyButton from './note_actions/reply_button.vue';
import TimelineEventButton from './note_actions/timeline_event_button.vue';
export default {
i18n: {
@ -23,6 +24,7 @@ export default {
components: {
GlIcon,
ReplyButton,
TimelineEventButton,
GlButton,
GlDropdownItem,
UserAccessRoleBadge,
@ -133,7 +135,8 @@ export default {
},
},
computed: {
...mapGetters(['getUserDataByProp', 'getNoteableData']),
...mapState(['isPromoteCommentToTimelineEventInProgress']),
...mapGetters(['getUserDataByProp', 'getNoteableData', 'canUserAddIncidentTimelineEvents']),
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
@ -199,7 +202,7 @@ export default {
},
},
methods: {
...mapActions(['toggleAwardRequest']),
...mapActions(['toggleAwardRequest', 'promoteCommentToTimelineEvent']),
onEdit() {
this.$emit('handleEdit');
},
@ -292,6 +295,12 @@ export default {
class="line-resolve-btn note-action-button"
@click="onResolve"
/>
<timeline-event-button
v-if="canUserAddIncidentTimelineEvents"
:note-id="noteId"
:is-promotion-in-progress="isPromoteCommentToTimelineEventInProgress"
@click-promote-comment-to-event="promoteCommentToTimelineEvent"
/>
<emoji-picker
v-if="canAwardEmoji"
toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary"

View File

@ -0,0 +1,49 @@
<script>
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
i18n: {
buttonText: __('Add comment to incident timeline'),
addError: __('Error promoting the note to timeline event: %{error}'),
addGenericError: __('Something went wrong while promoting the note to timeline event.'),
},
components: {
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
noteId: {
type: [String, Number],
required: true,
},
isPromotionInProgress: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
handleButtonClick() {
this.$emit('click-promote-comment-to-event', {
noteId: this.noteId,
addError: this.$options.i18n.addError,
addGenericError: this.$options.i18n.addGenericError,
});
},
},
};
</script>
<template>
<span v-gl-tooltip :title="$options.i18n.buttonText">
<gl-button
category="tertiary"
icon="clock"
:aria-label="$options.i18n.buttonText"
:disabled="isPromotionInProgress"
@click="handleButtonClick"
/>
</span>
</template>

View File

@ -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 = {

View File

@ -0,0 +1,8 @@
mutation PromoteTimelineEvent($input: TimelineEventPromoteFromNoteInput!) {
timelineEventPromoteFromNote(input: $input) {
timelineEvent {
id
}
errors
}
}

View File

@ -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),
};
}

View File

@ -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 },

View File

@ -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 }));

View File

@ -30,6 +30,7 @@ export default () => ({
isNotesFetched: false,
isLoading: true,
isLoadingDescriptionVersion: false,
isPromoteCommentToTimelineEventInProgress: false,
// holds endpoints and permissions provided through haml
notesData: {

View File

@ -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';

View File

@ -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;
},
};

View File

@ -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',

View File

@ -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())
);
},
},

View File

@ -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)}" } }

View File

@ -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 ""

View File

@ -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);
});
});

View File

@ -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(

View File

@ -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 });
});