app/assets/javascripts/notes/store/legacy_notes/actions.js (715 lines of code) (raw):

import $ from 'jquery'; import Vue from 'vue'; import { debounce } from 'lodash'; import actionCable from '~/actioncable_consumer'; import Api from '~/api'; import { createAlert, VARIANT_INFO } from '~/alert'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import { STATUS_CLOSED, STATUS_REOPENED, TYPE_ISSUE } from '~/issues/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/queries/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/queries/update_merge_request_lock.mutation.graphql'; import loadAwardsHandler from '~/awards_handler'; import { isInMRPage } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import sidebarTimeTrackingEventHub from '~/sidebar/event_hub'; import TaskList from '~/task_list'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_NOTE } from '~/graphql_shared/constants'; import { useBatchComments } from '~/batch_comments/store'; import { uuids } from '~/lib/utils/uuids'; import notesEventHub from '../../event_hub'; import promoteTimelineEvent from '../../graphql/promote_timeline_event.mutation.graphql'; import * as constants from '../../constants'; import * as types from '../../stores/mutation_types'; import * as utils from '../../stores/utils'; export function updateLockedAttribute({ locked, fullPath }) { const { iid, targetType } = this.getNoteableData; return utils.gqClient .mutate({ mutation: targetType === TYPE_ISSUE ? updateIssueLockMutation : updateMergeRequestLockMutation, variables: { input: { projectPath: fullPath, iid: String(iid), locked, }, }, }) .then(({ data }) => { const discussionLocked = targetType === TYPE_ISSUE ? data.issueSetLocked.issue.discussionLocked : data.mergeRequestSetLocked.mergeRequest.discussionLocked; this[types.SET_ISSUABLE_LOCK](discussionLocked); }); } export function expandDiscussion(data) { if (data.discussionId) { // tryStore only used for migration, refactor the store to avoid using this helper this.tryStore('legacyDiffs').renderFileForDiscussionId(data.discussionId); } this[types.EXPAND_DISCUSSION](data); } export function collapseDiscussion(data) { return this[types.COLLAPSE_DISCUSSION](data); } export function setNotesData(data) { return this[types.SET_NOTES_DATA](data); } export function setNoteableData(data) { return this[types.SET_NOTEABLE_DATA](data); } export function setConfidentiality(data) { return this[types.SET_ISSUE_CONFIDENTIAL](data); } export function setUserData(data) { return this[types.SET_USER_DATA](data); } export function setLastFetchedAt(data) { return this[types.SET_LAST_FETCHED_AT](data); } export function setInitialNotes(discussions) { return this[types.ADD_OR_UPDATE_DISCUSSIONS](discussions); } export function setTargetNoteHash(data) { return this[types.SET_TARGET_NOTE_HASH](data); } export function setNotesFetchedState(state) { return this[types.SET_NOTES_FETCHED_STATE](state); } export function toggleDiscussion(data) { return this[types.TOGGLE_DISCUSSION](data); } export function toggleAllDiscussions() { const expanded = this.allDiscussionsExpanded; this[types.SET_EXPAND_ALL_DISCUSSIONS](!expanded); } export function fetchDiscussions({ path, filter, persistFilter }) { let config = filter !== undefined ? { params: { notes_filter: filter, persist_filter: persistFilter } } : null; if (this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE) { config = { params: { notes_filter: 0, persist_filter: false } }; } if ( this.noteableType === constants.ISSUE_NOTEABLE_TYPE || this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE ) { return this.fetchDiscussionsBatch({ path, config, perPage: 20 }); } return axios.get(path, config).then(({ data }) => { this[types.ADD_OR_UPDATE_DISCUSSIONS](data); this[types.SET_FETCHING_DISCUSSIONS](false); this.updateResolvableDiscussionsCounts(); }); } export function fetchNotes() { if (this.isFetching) return null; this.setFetchingState(true); return this.fetchDiscussions(this.getFetchDiscussionsConfig) .then(() => this.initPolling()) .then(() => { this.setLoadingState(false); this.setNotesFetchedState(true); notesEventHub.$emit('fetchedNotesData'); this.setFetchingState(false); }) .catch(() => { this.setLoadingState(false); this.setNotesFetchedState(true); createAlert({ message: __('Something went wrong while fetching comments. Please try again.'), }); }); } export function initPolling() { if (this.isPollingInitialized) { return; } this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); const debouncedFetchUpdatedNotes = debounce(() => { this.fetchUpdatedNotes(); }, constants.FETCH_UPDATED_NOTES_DEBOUNCE_TIMEOUT); actionCable.subscriptions.create( { channel: 'Noteable::NotesChannel', project_id: this.notesData.projectId, group_id: this.notesData.groupId, noteable_type: this.notesData.noteableType, noteable_id: this.notesData.noteableId, }, { connected() { this.fetchUpdatedNotes(); }, received(data) { if (data.event === 'updated') { debouncedFetchUpdatedNotes(); } }, }, ); this[types.SET_IS_POLLING_INITIALIZED](true); } export function fetchDiscussionsBatch({ path, config, cursor, perPage }) { const params = { ...config?.params, per_page: perPage }; if (cursor) { params.cursor = cursor; } return axios.get(path, { params }).then(({ data, headers }) => { this[types.ADD_OR_UPDATE_DISCUSSIONS](data); if (headers && headers['x-next-page-cursor']) { const nextConfig = { ...config }; if (config?.params?.persist_filter) { delete nextConfig.params.notes_filter; delete nextConfig.params.persist_filter; } return this.fetchDiscussionsBatch({ path, config: nextConfig, cursor: headers['x-next-page-cursor'], perPage: Math.min(Math.round(perPage * 1.5), 100), }); } this[types.SET_DONE_FETCHING_BATCH_DISCUSSIONS](true); this[types.SET_FETCHING_DISCUSSIONS](false); this.updateResolvableDiscussionsCounts(); return undefined; }); } export function updateDiscussion(discussion) { if (discussion == null) return null; this[types.UPDATE_DISCUSSION](discussion); return utils.findNoteObjectById(this.discussions, discussion.id); } export function setDiscussionSortDirection({ direction, persist = true }) { this[types.SET_DISCUSSIONS_SORT]({ direction, persist }); } export function setTimelineView(enabled) { this[types.SET_TIMELINE_VIEW](enabled); } export function setSelectedCommentPosition(position) { this[types.SET_SELECTED_COMMENT_POSITION](position); } export function setSelectedCommentPositionHover(position) { this[types.SET_SELECTED_COMMENT_POSITION_HOVER](position); } export function removeNote(note) { const discussion = this.discussions.find(({ id }) => id === note.discussion_id); this[types.DELETE_NOTE](note); this.updateMergeRequestWidget(); this.updateResolvableDiscussionsCounts(); if (isInMRPage()) { // tryStore only used for migration, refactor the store to avoid using this helper this.tryStore('legacyDiffs').removeDiscussionsFromDiff(discussion); } } export function deleteNote(note) { return axios.delete(note.path).then(() => { this.removeNote(note); }); } export function updateNote({ endpoint, note }) { return axios.put(endpoint, note).then(({ data }) => { this[types.UPDATE_NOTE](data); this.startTaskList(); }); } export function updateOrCreateNotes(notes) { const debouncedFetchDiscussions = (isFetching) => { if (!isFetching) { this[types.SET_FETCHING_DISCUSSIONS](true); this.fetchDiscussions({ path: this.notesData.discussionsPath }); } else { if (isFetching !== true) { clearTimeout(this.currentlyFetchingDiscussions); } this[types.SET_FETCHING_DISCUSSIONS]( setTimeout(() => { this.fetchDiscussions({ path: this.notesData.discussionsPath }); }, constants.DISCUSSION_FETCH_TIMEOUT), ); } }; notes.forEach((note) => { if (this.notesById[note.id]) { this[types.UPDATE_NOTE](note); } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { const discussion = utils.findNoteObjectById(this.discussions, note.discussion_id); if (discussion) { this[types.ADD_NEW_REPLY_TO_DISCUSSION](note); } else if (note.type === constants.DIFF_NOTE && !note.base_discussion) { debouncedFetchDiscussions(this.currentlyFetchingDiscussions); } else { this[types.ADD_NEW_NOTE](note); } } else { this[types.ADD_NEW_NOTE](note); } }); } export function promoteCommentToTimelineEvent({ noteId, addError, addGenericError }) { this[types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS](true); // Set loading state return utils.gqClient .mutate({ mutation: promoteTimelineEvent, variables: { input: { noteId: convertToGraphQLId(TYPENAME_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; } createAlert({ message, captureError, error: errorObj, }); }) .finally(() => { this[types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS](false); // Revert loading state }); } export function replyToDiscussion({ endpoint, data: reply }) { return axios.post(endpoint, reply).then(({ data }) => { if (data.discussion) { this[types.UPDATE_DISCUSSION](data.discussion); this.updateOrCreateNotes(data.discussion.notes); this.updateMergeRequestWidget(); this.startTaskList(); this.updateResolvableDiscussionsCounts(); } else { this[types.ADD_NEW_REPLY_TO_DISCUSSION](data); } return data; }); } export function createNewNote({ endpoint, data: reply }) { return axios.post(endpoint, reply).then(({ data }) => { if (!data.errors) { this[types.ADD_NEW_NOTE](data); this.updateMergeRequestWidget(); this.startTaskList(); this.updateResolvableDiscussionsCounts(); } return data; }); } export function removePlaceholderNotes() { return this[types.REMOVE_PLACEHOLDER_NOTES](); } export function resolveDiscussion({ discussionId }) { const discussion = utils.findNoteObjectById(this.discussions, discussionId); const isResolved = this.isDiscussionResolved(discussionId); if (!discussion) { return Promise.reject(); } if (isResolved) { return Promise.resolve(); } return this.toggleResolveNote({ endpoint: discussion.resolve_path, isResolved, discussion: true, }); } export function toggleResolveNote({ endpoint, isResolved, discussion }) { const method = isResolved ? constants.UNRESOLVE_NOTE_METHOD_NAME : constants.RESOLVE_NOTE_METHOD_NAME; const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE; return axios[method](endpoint).then(({ data }) => { this[mutationType](data); this.updateResolvableDiscussionsCounts(); this.updateMergeRequestWidget(); }); } export function closeIssuable() { this.toggleStateButtonLoading(true); return axios.put(this.notesData.closePath).then(({ data }) => { this[types.CLOSE_ISSUE](); this.emitStateChangedEvent(data); this.toggleStateButtonLoading(false); }); } export function reopenIssuable() { this.toggleStateButtonLoading(true); return axios.put(this.notesData.reopenPath).then(({ data }) => { this[types.REOPEN_ISSUE](); this.emitStateChangedEvent(data); this.toggleStateButtonLoading(false); }); } export function toggleStateButtonLoading(value) { return this[types.TOGGLE_STATE_BUTTON_LOADING](value); } export function emitStateChangedEvent(data) { const event = new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, { detail: { data, isClosed: this.openState === STATUS_CLOSED, }, }); document.dispatchEvent(event); } export function toggleIssueLocalState(newState) { if (newState === STATUS_CLOSED) { this[types.CLOSE_ISSUE](); } else if (newState === STATUS_REOPENED) { this[types.REOPEN_ISSUE](); } } export function saveNote(noteData) { // For MR discussuions we need to post as `note[note]` and issue we use `note.note`. // For batch comments, we use draft_note const note = noteData.data.draft_note || noteData.data['note[note]'] || noteData.data.note.note; let placeholderText = note; const hasQuickActions = utils.hasQuickActions(placeholderText); const replyId = noteData.data.in_reply_to_discussion_id; let methodToDispatch; const postData = { ...noteData }; if (postData.isDraft === true) { methodToDispatch = replyId ? useBatchComments().addDraftToDiscussion : useBatchComments().createNewDraft; if (!postData.draft_note && noteData.note) { postData.draft_note = postData.note; delete postData.note; } } else { methodToDispatch = replyId ? this.replyToDiscussion : this.createNewNote; } this[types.REMOVE_PLACEHOLDER_NOTES](); // remove previous placeholders if (hasQuickActions) { placeholderText = utils.stripQuickActions(placeholderText); } if (placeholderText.length) { this[types.SHOW_PLACEHOLDER_NOTE]({ id: uuids()[0], noteBody: placeholderText, replyId, }); } if (hasQuickActions) { this[types.SHOW_PLACEHOLDER_NOTE]({ id: uuids()[0], isSystemNote: true, noteBody: utils.getQuickActionText(note), replyId, }); } const processQuickActions = (res) => { const { quick_actions_status: { messages = null, command_names: commandNames = [] } = {} } = res; if (commandNames?.indexOf('submit_review') >= 0) { useBatchComments().clearDrafts(); } /* The following reply means that quick actions have been successfully applied: {"commands_changes":{},"valid":false,"errors":{},"quick_actions_status":{"messages":["Commands applied"],"command_names":["due"],"commands_only":true}} */ if (hasQuickActions && messages) { // synchronizing the quick action with the sidebar widget // this is a temporary solution until we have confidentiality real-time updates if ( confidentialWidget.setConfidentiality && messages.some((m) => m.includes('Made this issue confidential')) ) { confidentialWidget.setConfidentiality(); } $('.js-gfm-input').trigger('clear-commands-cache.atwho'); createAlert({ message: messages || __('Commands applied'), variant: VARIANT_INFO, parent: noteData.flashContainer, }); } return res; }; const processEmojiAward = (res) => { const { commands_changes: commandsChanges } = res; const { emoji_award: emojiAward } = commandsChanges || {}; if (!emojiAward) { return res; } const votesBlock = $('.js-awards-block').eq(0); return loadAwardsHandler() .then((awardsHandler) => { awardsHandler.addAwardToEmojiBar(votesBlock, emojiAward); awardsHandler.scrollToAwards(); }) .catch(() => { createAlert({ message: __('Something went wrong while adding your award. Please try again.'), parent: noteData.flashContainer, }); }) .then(() => res); }; const processTimeTracking = (res) => { const { commands_changes: commandsChanges } = res; const { spend_time: spendTime, time_estimate: timeEstimate } = commandsChanges || {}; if (spendTime != null || timeEstimate != null) { sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', { commands_changes: commandsChanges, }); } return res; }; const removePlaceholder = (res) => { this[types.REMOVE_PLACEHOLDER_NOTES](); return res; }; return methodToDispatch(postData) .then(processQuickActions) .then(processEmojiAward) .then(processTimeTracking) .then(removePlaceholder); } export function setFetchingState(fetchingState) { return this[types.SET_NOTES_FETCHING_STATE](fetchingState); } const getFetchDataParams = (state) => { const endpoint = state.notesData.notesPath; const options = { headers: { 'X-Last-Fetched-At': state.lastFetchedAt ? `${state.lastFetchedAt}` : undefined, }, }; return { endpoint, options }; }; export function fetchUpdatedNotes() { const { endpoint, options } = getFetchDataParams(this); return axios .get(endpoint, options) .then(async ({ data }) => { if (this.isResolvingDiscussion) { return null; } if (data.notes?.length) { const botNote = data.notes?.find( (note) => note.author.user_type === 'duo_code_review_bot' && !note.system, ); if (botNote) { let discussions = this.discussions.filter((d) => d.id === botNote.discussion_id); if (!discussions.length) { discussions = this.discussions; } for (const discussion of discussions) { const systemNote = discussion.notes.find( (note) => note.author.user_type === 'duo_code_review_bot' && note.system, ); if (systemNote) { this.removeNote(systemNote); break; } } } await this.updateOrCreateNotes(data.notes); this.startTaskList(); this.updateResolvableDiscussionsCounts(); } this[types.SET_LAST_FETCHED_AT](data.last_fetched_at); return undefined; }) .catch(() => {}); } export function toggleAward({ awardName, noteId }) { this[types.TOGGLE_AWARD]({ awardName, note: this.notesById[noteId] }); } export function toggleAwardRequest(data) { const { endpoint, awardName } = data; return axios.post(endpoint, { name: awardName }).then(() => { this.toggleAward(data); }); } export function fetchDiscussionDiffLines(discussion) { return axios.get(discussion.truncated_diff_lines_path).then(({ data }) => { this[types.SET_DISCUSSION_DIFF_LINES]({ discussionId: discussion.id, diffLines: data.truncated_diff_lines, }); }); } export const updateMergeRequestWidget = () => { mrWidgetEventHub.$emit('mr.discussion.updated'); }; export function setLoadingState(data) { this[types.SET_NOTES_LOADING_STATE](data); } export function filterDiscussion({ path, filter, persistFilter }) { this[types.CLEAR_DISCUSSIONS](); this.setLoadingState(true); this.fetchDiscussions({ path, filter, persistFilter }) .then(() => { this.setLoadingState(false); this.setNotesFetchedState(true); }) .catch(() => { this.setLoadingState(false); this.setNotesFetchedState(true); createAlert({ message: __('Something went wrong while fetching comments. Please try again.'), }); }); } export function setCommentsDisabled(data) { this[types.DISABLE_COMMENTS](data); } export function startTaskList() { return Vue.nextTick( () => new TaskList({ dataType: 'note', fieldName: 'note', selector: '.notes .is-editable', onSuccess: () => this.startTaskList(), }), ); } export function updateResolvableDiscussionsCounts() { return this[types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS](); } export function submitSuggestion({ discussionId, suggestionId, flashContainer, message }) { const dispatchResolveDiscussion = () => this.resolveDiscussion({ discussionId }).catch(() => {}); this[types.SET_RESOLVING_DISCUSSION](true); return Api.applySuggestion(suggestionId, message) .then(dispatchResolveDiscussion) .catch((err) => { const defaultMessage = __( 'Something went wrong while applying the suggestion. Please try again.', ); const errorMessage = err.response.data?.message; const alertMessage = errorMessage || defaultMessage; createAlert({ message: alertMessage, parent: flashContainer, }); }) .finally(() => { this[types.SET_RESOLVING_DISCUSSION](false); }); } export function submitSuggestionBatch({ message, flashContainer }) { const suggestionIds = this.batchSuggestionsInfo.map(({ suggestionId }) => suggestionId); const resolveAllDiscussions = () => this.batchSuggestionsInfo.map((suggestionInfo) => { const { discussionId } = suggestionInfo; return this.resolveDiscussion({ discussionId }).catch(() => {}); }); this[types.SET_APPLYING_BATCH_STATE](true); this[types.SET_RESOLVING_DISCUSSION](true); return Api.applySuggestionBatch(suggestionIds, message) .then(() => Promise.all(resolveAllDiscussions())) .then(() => this[types.CLEAR_SUGGESTION_BATCH]()) .catch((err) => { const defaultMessage = __( 'Something went wrong while applying the batch of suggestions. Please try again.', ); const errorMessage = err.response.data?.message; const alertMessage = errorMessage || defaultMessage; createAlert({ message: alertMessage, parent: flashContainer, }); }) .finally(() => { this[types.SET_APPLYING_BATCH_STATE](false); this[types.SET_RESOLVING_DISCUSSION](false); }); } export function addSuggestionInfoToBatch({ suggestionId, noteId, discussionId }) { return this[types.ADD_SUGGESTION_TO_BATCH]({ suggestionId, noteId, discussionId }); } export function removeSuggestionInfoFromBatch(suggestionId) { return this[types.REMOVE_SUGGESTION_FROM_BATCH](suggestionId); } export function convertToDiscussion(noteId) { return this[types.CONVERT_TO_DISCUSSION](noteId); } export function removeConvertedDiscussion(noteId) { return this[types.REMOVE_CONVERTED_DISCUSSION](noteId); } export function setCurrentDiscussionId(discussionId) { return this[types.SET_CURRENT_DISCUSSION_ID](discussionId); } export function fetchDescriptionVersion({ endpoint, startingVersion, versionId }) { let requestUrl = endpoint; if (startingVersion) { requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl); } this.requestDescriptionVersion(); return axios .get(requestUrl) .then((res) => { this.receiveDescriptionVersion({ descriptionVersion: res.data, versionId }); }) .catch((error) => { this.receiveDescriptionVersionError(error); createAlert({ message: __('Something went wrong while fetching description changes. Please try again.'), }); }); } export function requestDescriptionVersion() { this[types.REQUEST_DESCRIPTION_VERSION](); } export function receiveDescriptionVersion(descriptionVersion) { this[types.RECEIVE_DESCRIPTION_VERSION](descriptionVersion); } export function receiveDescriptionVersionError(error) { this[types.RECEIVE_DESCRIPTION_VERSION_ERROR](error); } export function softDeleteDescriptionVersion({ endpoint, startingVersion, versionId }) { let requestUrl = endpoint; if (startingVersion) { requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl); } this.requestDeleteDescriptionVersion(); return axios .delete(requestUrl) .then(() => { this.receiveDeleteDescriptionVersion(versionId); }) .catch((error) => { this.receiveDeleteDescriptionVersionError(error); createAlert({ message: __('Something went wrong while deleting description changes. Please try again.'), }); // Throw an error here because a component like SystemNote - // needs to know if the request failed to reset its internal state. throw new Error(); }); } export function requestDeleteDescriptionVersion() { this[types.REQUEST_DELETE_DESCRIPTION_VERSION](); } export function receiveDeleteDescriptionVersion(versionId) { this[types.RECEIVE_DELETE_DESCRIPTION_VERSION]({ [versionId]: __('Deleted') }); } export function receiveDeleteDescriptionVersionError(error) { this[types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR](error); } export function updateAssignees(assignees) { this[types.UPDATE_ASSIGNEES](assignees); } export function updateDiscussionPosition(updatedPosition) { this[types.UPDATE_DISCUSSION_POSITION](updatedPosition); } export function updateMergeRequestFilters(newFilters) { return this[types.SET_MERGE_REQUEST_FILTERS](newFilters); }