Commit ed3034bb authored by Oswaldo Ferreira's avatar Oswaldo Ferreira 🌴 Committed by Phil Hughes

Allow suggesting single line changes in diffs

parent eb81c123
......@@ -25,6 +25,7 @@ const Api = {
userStatusPath: '/api/:version/users/:id/status',
userPostStatusPath: '/api/:version/user/status',
commitPath: '/api/:version/projects/:id/repository/commits',
applySuggestionPath: '/api/:version/suggestions/:id/apply',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
......@@ -185,6 +186,12 @@ const Api = {
});
},
applySuggestion(id) {
const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id));
return axios.put(url);
},
commitPipelines(projectId, sha) {
const encodedProjectId = projectId
.split('/')
......
......@@ -42,6 +42,11 @@ export default {
type: Object,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
changesEmptyStateIllustration: {
type: String,
required: false,
......@@ -208,6 +213,7 @@ export default {
v-for="file in diffFiles"
:key="file.newPath"
:file="file"
:help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
/>
</template>
......
......@@ -23,6 +23,11 @@ export default {
type: Object,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapState({
......@@ -74,11 +79,13 @@ export default {
v-if="isInlineView"
:diff-file="diffFile"
:diff-lines="diffFile.highlighted_diff_lines || []"
:help-page-path="helpPagePath"
/>
<parallel-diff-view
v-if="isParallelView"
:diff-file="diffFile"
:diff-lines="diffFile.parallel_diff_lines || []"
:help-page-path="helpPagePath"
/>
</template>
<diff-viewer
......
......@@ -13,6 +13,11 @@ export default {
type: Array,
required: true,
},
line: {
type: Object,
required: false,
default: null,
},
shouldCollapseDiscussions: {
type: Boolean,
required: false,
......@@ -23,6 +28,11 @@ export default {
required: false,
default: false,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
methods: {
...mapActions(['toggleDiscussion']),
......@@ -72,6 +82,8 @@ export default {
:render-diff-file="false"
:always-expanded="true"
:discussions-by-diff-order="true"
:line="line"
:help-page-path="helpPagePath"
@noteDeleted="deleteNoteHandler"
>
<span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill">
......
......@@ -23,6 +23,11 @@ export default {
type: Boolean,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -164,6 +169,7 @@ export default {
v-if="!isCollapsed && file.renderIt"
:class="{ hidden: isCollapsed || file.too_large }"
:diff-file="file"
:help-page-path="helpPagePath"
/>
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
<div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed">
......
......@@ -94,6 +94,7 @@ export default {
ref="noteForm"
:is-editing="true"
:line-code="line.line_code"
:line="line"
save-button-title="Comment"
class="diff-comment-form"
@cancelForm="handleCancelCommentForm"
......
......@@ -16,6 +16,11 @@ export default {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
className() {
......@@ -38,7 +43,12 @@ export default {
<tr v-if="shouldRender" :class="className" class="notes_holder">
<td class="notes_content" colspan="3">
<div class="content">
<diff-discussions v-if="line.discussions.length" :discussions="line.discussions" />
<diff-discussions
v-if="line.discussions.length"
:line="line"
:discussions="line.discussions"
:help-page-path="helpPagePath"
/>
<diff-line-note-form
v-if="line.hasForm"
:diff-file-hash="diffFileHash"
......
......@@ -17,6 +17,11 @@ export default {
type: Array,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapGetters('diffs', ['commitId']),
......@@ -47,6 +52,7 @@ export default {
:key="`icr-${index}`"
:diff-file-hash="diffFile.file_hash"
:line="line"
:help-page-path="helpPagePath"
/>
</template>
</tbody>
......
......@@ -20,6 +20,11 @@ export default {
type: Number,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
hasExpandedDiscussionOnLeft() {
......@@ -87,6 +92,8 @@ export default {
<diff-discussions
v-if="line.left.discussions.length"
:discussions="line.left.discussions"
:line="line.left"
:help-page-path="helpPagePath"
/>
</div>
<diff-line-note-form
......@@ -102,6 +109,8 @@ export default {
<diff-discussions
v-if="line.right.discussions.length"
:discussions="line.right.discussions"
:line="line.right"
:help-page-path="helpPagePath"
/>
</div>
<diff-line-note-form
......
......@@ -17,6 +17,11 @@ export default {
type: Array,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapGetters('diffs', ['commitId']),
......@@ -49,6 +54,7 @@ export default {
:line="line"
:diff-file-hash="diffFile.file_hash"
:line-index="index"
:help-page-path="helpPagePath"
/>
</template>
</tbody>
......
......@@ -16,6 +16,7 @@ export default function initDiffsApp(store) {
return {
endpoint: dataset.endpoint,
projectPath: dataset.projectPath,
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
changesEmptyStateIllustration: dataset.changesEmptyStateIllustration,
};
......@@ -31,6 +32,7 @@ export default function initDiffsApp(store) {
endpoint: this.endpoint,
currentUser: this.currentUser,
projectPath: this.projectPath,
helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
changesEmptyStateIllustration: this.changesEmptyStateIllustration,
},
......
......@@ -39,7 +39,14 @@ function blockTagText(text, textArea, blockTag, selected) {
}
}
function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, select }) {
function moveCursor({
textArea,
tag,
cursorOffset,
positionBetweenTags,
removedLastNewLine,
select,
}) {
var pos;
if (!textArea.setSelectionRange) {
return;
......@@ -61,11 +68,24 @@ function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, se
pos -= 1;
}
if (cursorOffset) {
pos -= cursorOffset;
}
return textArea.setSelectionRange(pos, pos);
}
}
export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }) {
export function insertMarkdownText({
textArea,
text,
tag,
cursorOffset,
blockTag,
selected,
wrap,
select,
}) {
var textToInsert,
selectedSplit,
startChar,
......@@ -154,20 +174,30 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr
return moveCursor({
textArea,
tag: tag.replace(textPlaceholder, selected),
cursorOffset,
positionBetweenTags: wrap && selected.length === 0,
removedLastNewLine,
select,
});
}
function updateText({ textArea, tag, blockTag, wrap, select }) {
function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
selected = selectedText(text, textArea);
selected = selectedText(text, textArea) || tagContent;
$textArea.focus();
return insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select });
return insertMarkdownText({
textArea,
text,
tag,
cursorOffset,
blockTag,
selected,
wrap,
select,
});
}
export function addMarkdownListeners(form) {
......@@ -178,9 +208,11 @@ export function addMarkdownListeners(form) {
return updateText({
textArea: $this.closest('.md-area').find('textarea'),
tag: $this.data('mdTag'),
cursorOffset: $this.data('mdCursorOffset'),
blockTag: $this.data('mdBlock'),
wrap: !$this.data('mdPrepend'),
select: $this.data('mdSelect'),
tagContent: $this.data('mdTagContent').toString(),
});
});
}
......
......@@ -33,6 +33,7 @@ export default function initMrNotes() {
noteableData,
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
helpPagePath: notesDataset.helpPagePath,
};
},
computed: {
......@@ -71,6 +72,7 @@ export default function initMrNotes() {
notesData: this.notesData,
userData: this.currentUserData,
shouldShow: this.activeTab === 'show',
helpPagePath: this.helpPagePath,
},
});
},
......
<script>
import { mapActions } from 'vuex';
import $ from 'jquery';
import noteEditedText from './note_edited_text.vue';
import noteAwardsList from './note_awards_list.vue';
import noteAttachment from './note_attachment.vue';
import noteForm from './note_form.vue';
import autosave from '../mixins/autosave';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
export default {
components: {
......@@ -12,6 +14,7 @@ export default {
noteAwardsList,
noteAttachment,
noteForm,
Suggestions,
},
mixins: [autosave],
props: {
......@@ -19,6 +22,11 @@ export default {
type: Object,
required: true,
},
line: {
type: Object,
required: false,
default: null,
},
canEdit: {
type: Boolean,
required: true,
......@@ -28,11 +36,22 @@ export default {
required: false,
default: false,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
noteBody() {
return this.note.note;
},
hasSuggestion() {
return this.note.suggestions && this.note.suggestions.length;
},
lineType() {
return this.line ? this.line.type : null;
},
},
mounted() {
this.renderGFM();
......@@ -53,6 +72,7 @@ export default {
}
},
methods: {
...mapActions(['submitSuggestion']),
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
......@@ -62,19 +82,35 @@ export default {
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelForm', shouldConfirm, isDirty);
},
applySuggestion({ suggestionId, flashContainer, callback }) {
const { discussion_id: discussionId, id: noteId } = this.note;
this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback });
},
},
};
</script>
<template>
<div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body">
<div class="note-text md" v-html="note.note_html"></div>
<suggestions
v-if="hasSuggestion && !isEditing"
:suggestions="note.suggestions"
:note-html="note.note_html"
:line-type="lineType"
:help-page-path="helpPagePath"
@apply="applySuggestion"
/>
<div v-else class="note-text md" v-html="note.note_html"></div>
<note-form
v-if="isEditing"
ref="noteForm"
:is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
:line="line"
:note="note"
:help-page-path="helpPagePath"
:markdown-version="note.cached_markdown_version"
@handleFormUpdate="handleFormUpdate"
@cancelForm="formCancelHandler"
......
<script>
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
......@@ -53,6 +54,21 @@ export default {
required: false,
default: false,
},
line: {
type: Object,
required: false,
default: null,
},
note: {
type: Object,
required: false,
default: null,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -79,7 +95,8 @@ export default {
return '#';
},
markdownPreviewPath() {
return this.getNoteableDataByProp('preview_note_path');
const notable = this.getNoteableDataByProp('preview_note_path');
return mergeUrlParams({ preview_suggestions: true }, notable);
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
......@@ -93,6 +110,18 @@ export default {
isDisabled() {
return !this.updatedNoteBody.length || this.isSubmitting;
},
discussionNote() {
const discussionNote = this.discussion.id
? this.getDiscussionLastNote(this.discussion)
: this.note;
return discussionNote || {};
},
canSuggest() {
return (
this.getNoteableData.can_receive_suggestion &&
(this.line && this.line.can_receive_suggestion)
);
},
},
watch: {
noteBody() {
......@@ -171,7 +200,11 @@ export default {
:markdown-docs-path="markdownDocsPath"
:markdown-version="markdownVersion"
:quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:note="discussionNote"
:can-suggest="canSuggest"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
>
<textarea
id="note_note"
......
......@@ -49,6 +49,11 @@ export default {
type: Object,
required: true,
},
line: {
type: Object,
required: false,
default: null,
},
renderDiffFile: {
type: Boolean,
required: false,
......@@ -64,6 +69,11 @@ export default {
required: false,
default: false,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
const { diff_discussion: isDiffDiscussion, resolved } = this.discussion;
......@@ -194,6 +204,13 @@ export default {
false,
);
},
diffLine() {
if (this.discussion.diff_discussion && this.discussion.truncated_diff_lines) {
return this.discussion.truncated_diff_lines.slice(-1)[0];
}
return this.line;
},
},
watch: {
isReplying() {
......@@ -357,6 +374,8 @@ Please check your network connection and try again.`;
<component
:is="componentName(initialDiscussion)"
:note="componentData(initialDiscussion)"
:line="line"
:help-page-path="helpPagePath"
@handleDeleteNote="deleteNoteHandler"
>
<slot slot="avatar-badge" name="avatar-badge"></slot>
......@@ -373,6 +392,8 @@ Please check your network connection and try again.`;
v-for="note in replies"
:key="note.id"
:note="componentData(note)"
:help-page-path="helpPagePath"
:line="line"
@handleDeleteNote="deleteNoteHandler"
/>
</template>
......@@ -383,6 +404,8 @@ Please check your network connection and try again.`;
v-for="(note, index) in discussion.notes"
:key="note.id"
:note="componentData(note)"
:help-page-path="helpPagePath"
:line="diffLine"
@handleDeleteNote="deleteNoteHandler"
>
<slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
......@@ -447,6 +470,7 @@ Please check your network connection and try again.`;
ref="noteForm"
:discussion="discussion"
:is-editing="false"
:line="diffLine"
save-button-title="Comment"
@handleFormUpdate="saveReply"
@cancelForm="cancelReplyForm"
......
......@@ -27,6 +27,16 @@ export default {
type: Object,
required: true,
},
line: {
type: Object,
required: false,
default: null,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -220,8 +230,10 @@ export default {
<note-body
ref="noteBody"
:note="note"
:line="line"
:can-edit="note.current_user.can_edit"
:is-editing="isEditing"
:help-page-path="helpPagePath"
@handleFormUpdate="formUpdateHandler"
@cancelForm="formCancelHandler"
/>
......
......@@ -49,6 +49,11 @@ export default {
required: false,
default: 0,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -206,6 +211,7 @@ export default {
:key="discussion.id"
:discussion="discussion"
:render-diff-file="true"
:help-page-path="helpPagePath"
/>
</template>
</ul>
......
import Vue from 'vue';
import Api from '~/api';
import VueResource from 'vue-resource';
import * as constants from '../constants';
......@@ -44,4 +45,7 @@ export default {
toggleIssueState(endpoint, data) {
return Vue.http.put(endpoint, data);
},
applySuggestion(id) {
return Api.applySuggestion(id);
},
};
......@@ -405,5 +405,25 @@ export const startTaskList = ({ dispatch }) =>
export const updateResolvableDiscussonsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
export const submitSuggestion = (
{ commit },
{ discussionId, noteId, suggestionId, flashContainer, callback },
) => {
service
.applySuggestion(suggestionId)
.then(() => {
commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId });
callback();
})
.catch(() => {
Flash(
__('Something went wrong while applying the suggestion. Please try again.'),
'alert',
flashContainer,
);
callback();
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -20,6 +20,7 @@ export default () => ({
userData: {},
noteableData: {
current_user: {},
preview_note_path: 'path/to/preview',
},
commentsDisabled: false,
resolvableDiscussionsCount: 0,
......
......@@ -16,6 +16,7 @@ export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES';
export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';