Allow suggesting single line changes in diffs
This commit is contained in:
parent
eb81c1239e
commit
ed3034bbb7
|
@ -25,6 +25,7 @@ const Api = {
|
||||||
userStatusPath: '/api/:version/users/:id/status',
|
userStatusPath: '/api/:version/users/:id/status',
|
||||||
userPostStatusPath: '/api/:version/user/status',
|
userPostStatusPath: '/api/:version/user/status',
|
||||||
commitPath: '/api/:version/projects/:id/repository/commits',
|
commitPath: '/api/:version/projects/:id/repository/commits',
|
||||||
|
applySuggestionPath: '/api/:version/suggestions/:id/apply',
|
||||||
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
|
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
|
||||||
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
|
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
|
||||||
createBranchPath: '/api/:version/projects/:id/repository/branches',
|
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) {
|
commitPipelines(projectId, sha) {
|
||||||
const encodedProjectId = projectId
|
const encodedProjectId = projectId
|
||||||
.split('/')
|
.split('/')
|
||||||
|
|
|
@ -42,6 +42,11 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
changesEmptyStateIllustration: {
|
changesEmptyStateIllustration: {
|
||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
|
@ -208,6 +213,7 @@ export default {
|
||||||
v-for="file in diffFiles"
|
v-for="file in diffFiles"
|
||||||
:key="file.newPath"
|
:key="file.newPath"
|
||||||
:file="file"
|
:file="file"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
:can-current-user-fork="canCurrentUserFork"
|
:can-current-user-fork="canCurrentUserFork"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -23,6 +23,11 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState({
|
...mapState({
|
||||||
|
@ -74,11 +79,13 @@ export default {
|
||||||
v-if="isInlineView"
|
v-if="isInlineView"
|
||||||
:diff-file="diffFile"
|
:diff-file="diffFile"
|
||||||
:diff-lines="diffFile.highlighted_diff_lines || []"
|
:diff-lines="diffFile.highlighted_diff_lines || []"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
/>
|
/>
|
||||||
<parallel-diff-view
|
<parallel-diff-view
|
||||||
v-if="isParallelView"
|
v-if="isParallelView"
|
||||||
:diff-file="diffFile"
|
:diff-file="diffFile"
|
||||||
:diff-lines="diffFile.parallel_diff_lines || []"
|
:diff-lines="diffFile.parallel_diff_lines || []"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<diff-viewer
|
<diff-viewer
|
||||||
|
|
|
@ -13,6 +13,11 @@ export default {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
line: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
shouldCollapseDiscussions: {
|
shouldCollapseDiscussions: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
|
@ -23,6 +28,11 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['toggleDiscussion']),
|
...mapActions(['toggleDiscussion']),
|
||||||
|
@ -72,6 +82,8 @@ export default {
|
||||||
:render-diff-file="false"
|
:render-diff-file="false"
|
||||||
:always-expanded="true"
|
:always-expanded="true"
|
||||||
:discussions-by-diff-order="true"
|
:discussions-by-diff-order="true"
|
||||||
|
:line="line"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
@noteDeleted="deleteNoteHandler"
|
@noteDeleted="deleteNoteHandler"
|
||||||
>
|
>
|
||||||
<span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill">
|
<span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill">
|
||||||
|
|
|
@ -23,6 +23,11 @@ export default {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -164,6 +169,7 @@ export default {
|
||||||
v-if="!isCollapsed && file.renderIt"
|
v-if="!isCollapsed && file.renderIt"
|
||||||
:class="{ hidden: isCollapsed || file.too_large }"
|
:class="{ hidden: isCollapsed || file.too_large }"
|
||||||
:diff-file="file"
|
:diff-file="file"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
/>
|
/>
|
||||||
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
|
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
|
||||||
<div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed">
|
<div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed">
|
||||||
|
|
|
@ -94,6 +94,7 @@ export default {
|
||||||
ref="noteForm"
|
ref="noteForm"
|
||||||
:is-editing="true"
|
:is-editing="true"
|
||||||
:line-code="line.line_code"
|
:line-code="line.line_code"
|
||||||
|
:line="line"
|
||||||
save-button-title="Comment"
|
save-button-title="Comment"
|
||||||
class="diff-comment-form"
|
class="diff-comment-form"
|
||||||
@cancelForm="handleCancelCommentForm"
|
@cancelForm="handleCancelCommentForm"
|
||||||
|
|
|
@ -16,6 +16,11 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
className() {
|
className() {
|
||||||
|
@ -38,7 +43,12 @@ export default {
|
||||||
<tr v-if="shouldRender" :class="className" class="notes_holder">
|
<tr v-if="shouldRender" :class="className" class="notes_holder">
|
||||||
<td class="notes_content" colspan="3">
|
<td class="notes_content" colspan="3">
|
||||||
<div class="content">
|
<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
|
<diff-line-note-form
|
||||||
v-if="line.hasForm"
|
v-if="line.hasForm"
|
||||||
:diff-file-hash="diffFileHash"
|
:diff-file-hash="diffFileHash"
|
||||||
|
|
|
@ -17,6 +17,11 @@ export default {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters('diffs', ['commitId']),
|
...mapGetters('diffs', ['commitId']),
|
||||||
|
@ -47,6 +52,7 @@ export default {
|
||||||
:key="`icr-${index}`"
|
:key="`icr-${index}`"
|
||||||
:diff-file-hash="diffFile.file_hash"
|
:diff-file-hash="diffFile.file_hash"
|
||||||
:line="line"
|
:line="line"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -20,6 +20,11 @@ export default {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
hasExpandedDiscussionOnLeft() {
|
hasExpandedDiscussionOnLeft() {
|
||||||
|
@ -87,6 +92,8 @@ export default {
|
||||||
<diff-discussions
|
<diff-discussions
|
||||||
v-if="line.left.discussions.length"
|
v-if="line.left.discussions.length"
|
||||||
:discussions="line.left.discussions"
|
:discussions="line.left.discussions"
|
||||||
|
:line="line.left"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<diff-line-note-form
|
<diff-line-note-form
|
||||||
|
@ -102,6 +109,8 @@ export default {
|
||||||
<diff-discussions
|
<diff-discussions
|
||||||
v-if="line.right.discussions.length"
|
v-if="line.right.discussions.length"
|
||||||
:discussions="line.right.discussions"
|
:discussions="line.right.discussions"
|
||||||
|
:line="line.right"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<diff-line-note-form
|
<diff-line-note-form
|
||||||
|
|
|
@ -17,6 +17,11 @@ export default {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters('diffs', ['commitId']),
|
...mapGetters('diffs', ['commitId']),
|
||||||
|
@ -49,6 +54,7 @@ export default {
|
||||||
:line="line"
|
:line="line"
|
||||||
:diff-file-hash="diffFile.file_hash"
|
:diff-file-hash="diffFile.file_hash"
|
||||||
:line-index="index"
|
:line-index="index"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -16,6 +16,7 @@ export default function initDiffsApp(store) {
|
||||||
return {
|
return {
|
||||||
endpoint: dataset.endpoint,
|
endpoint: dataset.endpoint,
|
||||||
projectPath: dataset.projectPath,
|
projectPath: dataset.projectPath,
|
||||||
|
helpPagePath: dataset.helpPagePath,
|
||||||
currentUser: JSON.parse(dataset.currentUserData) || {},
|
currentUser: JSON.parse(dataset.currentUserData) || {},
|
||||||
changesEmptyStateIllustration: dataset.changesEmptyStateIllustration,
|
changesEmptyStateIllustration: dataset.changesEmptyStateIllustration,
|
||||||
};
|
};
|
||||||
|
@ -31,6 +32,7 @@ export default function initDiffsApp(store) {
|
||||||
endpoint: this.endpoint,
|
endpoint: this.endpoint,
|
||||||
currentUser: this.currentUser,
|
currentUser: this.currentUser,
|
||||||
projectPath: this.projectPath,
|
projectPath: this.projectPath,
|
||||||
|
helpPagePath: this.helpPagePath,
|
||||||
shouldShow: this.activeTab === 'diffs',
|
shouldShow: this.activeTab === 'diffs',
|
||||||
changesEmptyStateIllustration: this.changesEmptyStateIllustration,
|
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;
|
var pos;
|
||||||
if (!textArea.setSelectionRange) {
|
if (!textArea.setSelectionRange) {
|
||||||
return;
|
return;
|
||||||
|
@ -61,11 +68,24 @@ function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, se
|
||||||
pos -= 1;
|
pos -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cursorOffset) {
|
||||||
|
pos -= cursorOffset;
|
||||||
|
}
|
||||||
|
|
||||||
return textArea.setSelectionRange(pos, pos);
|
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,
|
var textToInsert,
|
||||||
selectedSplit,
|
selectedSplit,
|
||||||
startChar,
|
startChar,
|
||||||
|
@ -154,20 +174,30 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr
|
||||||
return moveCursor({
|
return moveCursor({
|
||||||
textArea,
|
textArea,
|
||||||
tag: tag.replace(textPlaceholder, selected),
|
tag: tag.replace(textPlaceholder, selected),
|
||||||
|
cursorOffset,
|
||||||
positionBetweenTags: wrap && selected.length === 0,
|
positionBetweenTags: wrap && selected.length === 0,
|
||||||
removedLastNewLine,
|
removedLastNewLine,
|
||||||
select,
|
select,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateText({ textArea, tag, blockTag, wrap, select }) {
|
function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
|
||||||
var $textArea, selected, text;
|
var $textArea, selected, text;
|
||||||
$textArea = $(textArea);
|
$textArea = $(textArea);
|
||||||
textArea = $textArea.get(0);
|
textArea = $textArea.get(0);
|
||||||
text = $textArea.val();
|
text = $textArea.val();
|
||||||
selected = selectedText(text, textArea);
|
selected = selectedText(text, textArea) || tagContent;
|
||||||
$textArea.focus();
|
$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) {
|
export function addMarkdownListeners(form) {
|
||||||
|
@ -178,9 +208,11 @@ export function addMarkdownListeners(form) {
|
||||||
return updateText({
|
return updateText({
|
||||||
textArea: $this.closest('.md-area').find('textarea'),
|
textArea: $this.closest('.md-area').find('textarea'),
|
||||||
tag: $this.data('mdTag'),
|
tag: $this.data('mdTag'),
|
||||||
|
cursorOffset: $this.data('mdCursorOffset'),
|
||||||
blockTag: $this.data('mdBlock'),
|
blockTag: $this.data('mdBlock'),
|
||||||
wrap: !$this.data('mdPrepend'),
|
wrap: !$this.data('mdPrepend'),
|
||||||
select: $this.data('mdSelect'),
|
select: $this.data('mdSelect'),
|
||||||
|
tagContent: $this.data('mdTagContent').toString(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ export default function initMrNotes() {
|
||||||
noteableData,
|
noteableData,
|
||||||
currentUserData: JSON.parse(notesDataset.currentUserData),
|
currentUserData: JSON.parse(notesDataset.currentUserData),
|
||||||
notesData: JSON.parse(notesDataset.notesData),
|
notesData: JSON.parse(notesDataset.notesData),
|
||||||
|
helpPagePath: notesDataset.helpPagePath,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -71,6 +72,7 @@ export default function initMrNotes() {
|
||||||
notesData: this.notesData,
|
notesData: this.notesData,
|
||||||
userData: this.currentUserData,
|
userData: this.currentUserData,
|
||||||
shouldShow: this.activeTab === 'show',
|
shouldShow: this.activeTab === 'show',
|
||||||
|
helpPagePath: this.helpPagePath,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { mapActions } from 'vuex';
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import noteEditedText from './note_edited_text.vue';
|
import noteEditedText from './note_edited_text.vue';
|
||||||
import noteAwardsList from './note_awards_list.vue';
|
import noteAwardsList from './note_awards_list.vue';
|
||||||
import noteAttachment from './note_attachment.vue';
|
import noteAttachment from './note_attachment.vue';
|
||||||
import noteForm from './note_form.vue';
|
import noteForm from './note_form.vue';
|
||||||
import autosave from '../mixins/autosave';
|
import autosave from '../mixins/autosave';
|
||||||
|
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
@ -12,6 +14,7 @@ export default {
|
||||||
noteAwardsList,
|
noteAwardsList,
|
||||||
noteAttachment,
|
noteAttachment,
|
||||||
noteForm,
|
noteForm,
|
||||||
|
Suggestions,
|
||||||
},
|
},
|
||||||
mixins: [autosave],
|
mixins: [autosave],
|
||||||
props: {
|
props: {
|
||||||
|
@ -19,6 +22,11 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
line: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
canEdit: {
|
canEdit: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -28,11 +36,22 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
noteBody() {
|
noteBody() {
|
||||||
return this.note.note;
|
return this.note.note;
|
||||||
},
|
},
|
||||||
|
hasSuggestion() {
|
||||||
|
return this.note.suggestions && this.note.suggestions.length;
|
||||||
|
},
|
||||||
|
lineType() {
|
||||||
|
return this.line ? this.line.type : null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.renderGFM();
|
this.renderGFM();
|
||||||
|
@ -53,6 +72,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
...mapActions(['submitSuggestion']),
|
||||||
renderGFM() {
|
renderGFM() {
|
||||||
$(this.$refs['note-body']).renderGFM();
|
$(this.$refs['note-body']).renderGFM();
|
||||||
},
|
},
|
||||||
|
@ -62,19 +82,35 @@ export default {
|
||||||
formCancelHandler(shouldConfirm, isDirty) {
|
formCancelHandler(shouldConfirm, isDirty) {
|
||||||
this.$emit('cancelForm', 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body">
|
<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
|
<note-form
|
||||||
v-if="isEditing"
|
v-if="isEditing"
|
||||||
ref="noteForm"
|
ref="noteForm"
|
||||||
:is-editing="isEditing"
|
:is-editing="isEditing"
|
||||||
:note-body="noteBody"
|
:note-body="noteBody"
|
||||||
:note-id="note.id"
|
:note-id="note.id"
|
||||||
|
:line="line"
|
||||||
|
:note="note"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
:markdown-version="note.cached_markdown_version"
|
:markdown-version="note.cached_markdown_version"
|
||||||
@handleFormUpdate="handleFormUpdate"
|
@handleFormUpdate="handleFormUpdate"
|
||||||
@cancelForm="formCancelHandler"
|
@cancelForm="formCancelHandler"
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { mergeUrlParams } from '~/lib/utils/url_utility';
|
||||||
import { mapGetters, mapActions } from 'vuex';
|
import { mapGetters, mapActions } from 'vuex';
|
||||||
import eventHub from '../event_hub';
|
import eventHub from '../event_hub';
|
||||||
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
|
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
|
||||||
|
@ -53,6 +54,21 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
line: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -79,7 +95,8 @@ export default {
|
||||||
return '#';
|
return '#';
|
||||||
},
|
},
|
||||||
markdownPreviewPath() {
|
markdownPreviewPath() {
|
||||||
return this.getNoteableDataByProp('preview_note_path');
|
const notable = this.getNoteableDataByProp('preview_note_path');
|
||||||
|
return mergeUrlParams({ preview_suggestions: true }, notable);
|
||||||
},
|
},
|
||||||
markdownDocsPath() {
|
markdownDocsPath() {
|
||||||
return this.getNotesDataByProp('markdownDocsPath');
|
return this.getNotesDataByProp('markdownDocsPath');
|
||||||
|
@ -93,6 +110,18 @@ export default {
|
||||||
isDisabled() {
|
isDisabled() {
|
||||||
return !this.updatedNoteBody.length || this.isSubmitting;
|
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: {
|
watch: {
|
||||||
noteBody() {
|
noteBody() {
|
||||||
|
@ -171,7 +200,11 @@ export default {
|
||||||
:markdown-docs-path="markdownDocsPath"
|
:markdown-docs-path="markdownDocsPath"
|
||||||
:markdown-version="markdownVersion"
|
:markdown-version="markdownVersion"
|
||||||
:quick-actions-docs-path="quickActionsDocsPath"
|
:quick-actions-docs-path="quickActionsDocsPath"
|
||||||
|
:line="line"
|
||||||
|
:note="discussionNote"
|
||||||
|
:can-suggest="canSuggest"
|
||||||
:add-spacing-classes="false"
|
:add-spacing-classes="false"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
id="note_note"
|
id="note_note"
|
||||||
|
|
|
@ -49,6 +49,11 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
line: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
renderDiffFile: {
|
renderDiffFile: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
|
@ -64,6 +69,11 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
const { diff_discussion: isDiffDiscussion, resolved } = this.discussion;
|
const { diff_discussion: isDiffDiscussion, resolved } = this.discussion;
|
||||||
|
@ -194,6 +204,13 @@ export default {
|
||||||
false,
|
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: {
|
watch: {
|
||||||
isReplying() {
|
isReplying() {
|
||||||
|
@ -357,6 +374,8 @@ Please check your network connection and try again.`;
|
||||||
<component
|
<component
|
||||||
:is="componentName(initialDiscussion)"
|
:is="componentName(initialDiscussion)"
|
||||||
:note="componentData(initialDiscussion)"
|
:note="componentData(initialDiscussion)"
|
||||||
|
:line="line"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
@handleDeleteNote="deleteNoteHandler"
|
@handleDeleteNote="deleteNoteHandler"
|
||||||
>
|
>
|
||||||
<slot slot="avatar-badge" name="avatar-badge"></slot>
|
<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"
|
v-for="note in replies"
|
||||||
:key="note.id"
|
:key="note.id"
|
||||||
:note="componentData(note)"
|
:note="componentData(note)"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
|
:line="line"
|
||||||
@handleDeleteNote="deleteNoteHandler"
|
@handleDeleteNote="deleteNoteHandler"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
@ -383,6 +404,8 @@ Please check your network connection and try again.`;
|
||||||
v-for="(note, index) in discussion.notes"
|
v-for="(note, index) in discussion.notes"
|
||||||
:key="note.id"
|
:key="note.id"
|
||||||
:note="componentData(note)"
|
:note="componentData(note)"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
|
:line="diffLine"
|
||||||
@handleDeleteNote="deleteNoteHandler"
|
@handleDeleteNote="deleteNoteHandler"
|
||||||
>
|
>
|
||||||
<slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
|
<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"
|
ref="noteForm"
|
||||||
:discussion="discussion"
|
:discussion="discussion"
|
||||||
:is-editing="false"
|
:is-editing="false"
|
||||||
|
:line="diffLine"
|
||||||
save-button-title="Comment"
|
save-button-title="Comment"
|
||||||
@handleFormUpdate="saveReply"
|
@handleFormUpdate="saveReply"
|
||||||
@cancelForm="cancelReplyForm"
|
@cancelForm="cancelReplyForm"
|
||||||
|
|
|
@ -27,6 +27,16 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
line: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -220,8 +230,10 @@ export default {
|
||||||
<note-body
|
<note-body
|
||||||
ref="noteBody"
|
ref="noteBody"
|
||||||
:note="note"
|
:note="note"
|
||||||
|
:line="line"
|
||||||
:can-edit="note.current_user.can_edit"
|
:can-edit="note.current_user.can_edit"
|
||||||
:is-editing="isEditing"
|
:is-editing="isEditing"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
@handleFormUpdate="formUpdateHandler"
|
@handleFormUpdate="formUpdateHandler"
|
||||||
@cancelForm="formCancelHandler"
|
@cancelForm="formCancelHandler"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -49,6 +49,11 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -206,6 +211,7 @@ export default {
|
||||||
:key="discussion.id"
|
:key="discussion.id"
|
||||||
:discussion="discussion"
|
:discussion="discussion"
|
||||||
:render-diff-file="true"
|
:render-diff-file="true"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
|
import Api from '~/api';
|
||||||
import VueResource from 'vue-resource';
|
import VueResource from 'vue-resource';
|
||||||
import * as constants from '../constants';
|
import * as constants from '../constants';
|
||||||
|
|
||||||
|
@ -44,4 +45,7 @@ export default {
|
||||||
toggleIssueState(endpoint, data) {
|
toggleIssueState(endpoint, data) {
|
||||||
return Vue.http.put(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 }) =>
|
export const updateResolvableDiscussonsCounts = ({ commit }) =>
|
||||||
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
|
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
|
// prevent babel-plugin-rewire from generating an invalid default during karma tests
|
||||||
export default () => {};
|
export default () => {};
|
||||||
|
|
|
@ -20,6 +20,7 @@ export default () => ({
|
||||||
userData: {},
|
userData: {},
|
||||||
noteableData: {
|
noteableData: {
|
||||||
current_user: {},
|
current_user: {},
|
||||||
|
preview_note_path: 'path/to/preview',
|
||||||
},
|
},
|
||||||
commentsDisabled: false,
|
commentsDisabled: false,
|
||||||
resolvableDiscussionsCount: 0,
|
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_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
|
||||||
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
|
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
|
||||||
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
|
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
|
||||||
|
export const APPLY_SUGGESTION = 'APPLY_SUGGESTION';
|
||||||
|
|
||||||
// DISCUSSION
|
// DISCUSSION
|
||||||
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
|
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
|
||||||
|
|
|
@ -197,6 +197,17 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[types.APPLY_SUGGESTION](state, { noteId, discussionId, suggestionId }) {
|
||||||
|
const noteObj = utils.findNoteObjectById(state.discussions, discussionId);
|
||||||
|
const comment = utils.findNoteObjectById(noteObj.notes, noteId);
|
||||||
|
|
||||||
|
comment.suggestions = comment.suggestions.map(suggestion => ({
|
||||||
|
...suggestion,
|
||||||
|
applied: suggestion.applied || suggestion.id === suggestionId,
|
||||||
|
appliable: false,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
[types.UPDATE_DISCUSSION](state, noteData) {
|
[types.UPDATE_DISCUSSION](state, noteData) {
|
||||||
const note = noteData;
|
const note = noteData;
|
||||||
const selectedDiscussion = state.discussions.find(disc => disc.id === note.id);
|
const selectedDiscussion = state.discussions.find(disc => disc.id === note.id);
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
<script>
|
<script>
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
|
import _ from 'underscore';
|
||||||
import { __ } from '~/locale';
|
import { __ } from '~/locale';
|
||||||
|
import { stripHtml } from '~/lib/utils/text_utility';
|
||||||
import Flash from '../../../flash';
|
import Flash from '../../../flash';
|
||||||
import GLForm from '../../../gl_form';
|
import GLForm from '../../../gl_form';
|
||||||
import markdownHeader from './header.vue';
|
import markdownHeader from './header.vue';
|
||||||
import markdownToolbar from './toolbar.vue';
|
import markdownToolbar from './toolbar.vue';
|
||||||
import icon from '../icon.vue';
|
import icon from '../icon.vue';
|
||||||
|
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
markdownHeader,
|
markdownHeader,
|
||||||
markdownToolbar,
|
markdownToolbar,
|
||||||
icon,
|
icon,
|
||||||
|
Suggestions,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
markdownPreviewPath: {
|
markdownPreviewPath: {
|
||||||
|
@ -48,12 +52,33 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
line: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
canSuggest: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
markdownPreview: '',
|
markdownPreview: '',
|
||||||
referencedCommands: '',
|
referencedCommands: '',
|
||||||
referencedUsers: '',
|
referencedUsers: '',
|
||||||
|
hasSuggestion: false,
|
||||||
markdownPreviewLoading: false,
|
markdownPreviewLoading: false,
|
||||||
previewMarkdown: false,
|
previewMarkdown: false,
|
||||||
};
|
};
|
||||||
|
@ -63,6 +88,39 @@ export default {
|
||||||
const referencedUsersThreshold = 10;
|
const referencedUsersThreshold = 10;
|
||||||
return this.referencedUsers.length >= referencedUsersThreshold;
|
return this.referencedUsers.length >= referencedUsersThreshold;
|
||||||
},
|
},
|
||||||
|
lineContent() {
|
||||||
|
const FIRST_CHAR_REGEX = /^(\+|-)/;
|
||||||
|
const [firstSuggestion] = this.suggestions;
|
||||||
|
if (firstSuggestion) {
|
||||||
|
return firstSuggestion.from_content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.line) {
|
||||||
|
const { rich_text: richText, text } = this.line;
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
return text.replace(FIRST_CHAR_REGEX, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return _.unescape(stripHtml(richText).replace(/\n/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
lineNumber() {
|
||||||
|
let lineNumber;
|
||||||
|
if (this.line) {
|
||||||
|
const { new_line: newLine, old_line: oldLine } = this.line;
|
||||||
|
lineNumber = newLine || oldLine;
|
||||||
|
}
|
||||||
|
return lineNumber;
|
||||||
|
},
|
||||||
|
suggestions() {
|
||||||
|
return this.note.suggestions || [];
|
||||||
|
},
|
||||||
|
lineType() {
|
||||||
|
return this.line ? this.line.type : '';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
/*
|
/*
|
||||||
|
@ -122,6 +180,7 @@ export default {
|
||||||
if (data.references) {
|
if (data.references) {
|
||||||
this.referencedCommands = data.references.commands;
|
this.referencedCommands = data.references.commands;
|
||||||
this.referencedUsers = data.references.users;
|
this.referencedUsers = data.references.users;
|
||||||
|
this.hasSuggestion = data.references.suggestions && data.references.suggestions.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
@ -147,6 +206,8 @@ export default {
|
||||||
>
|
>
|
||||||
<markdown-header
|
<markdown-header
|
||||||
:preview-markdown="previewMarkdown"
|
:preview-markdown="previewMarkdown"
|
||||||
|
:line-content="lineContent"
|
||||||
|
:can-suggest="canSuggest"
|
||||||
@preview-markdown="showPreviewTab"
|
@preview-markdown="showPreviewTab"
|
||||||
@write-markdown="showWriteTab"
|
@write-markdown="showWriteTab"
|
||||||
/>
|
/>
|
||||||
|
@ -163,19 +224,39 @@ export default {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="hasSuggestion">
|
||||||
|
<div
|
||||||
|
v-show="previewMarkdown"
|
||||||
|
ref="markdown-preview"
|
||||||
|
class="md-preview js-vue-md-preview md md-preview-holder"
|
||||||
|
>
|
||||||
|
<suggestions
|
||||||
|
v-if="hasSuggestion"
|
||||||
|
:note-html="markdownPreview"
|
||||||
|
:from-line="lineNumber"
|
||||||
|
:from-content="lineContent"
|
||||||
|
:line-type="lineType"
|
||||||
|
:disabled="true"
|
||||||
|
:suggestions="suggestions"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-show="previewMarkdown"
|
v-show="previewMarkdown"
|
||||||
ref="markdown-preview"
|
ref="markdown-preview"
|
||||||
class="md-preview js-vue-md-preview md md-preview-holder"
|
class="md-preview js-vue-md-preview md md-preview-holder"
|
||||||
v-html="markdownPreview"
|
v-html="markdownPreview"
|
||||||
></div>
|
></div>
|
||||||
|
</template>
|
||||||
<template v-if="previewMarkdown && !markdownPreviewLoading">
|
<template v-if="previewMarkdown && !markdownPreviewLoading">
|
||||||
<div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div>
|
<div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div>
|
||||||
<div v-if="shouldShowReferencedUsers" class="referenced-users">
|
<div v-if="shouldShowReferencedUsers" class="referenced-users">
|
||||||
<span>
|
<span>
|
||||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"> </i> You are about to add
|
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> You are about to add
|
||||||
<strong>
|
<strong>
|
||||||
<span class="js-referenced-users-count"> {{ referencedUsers.length }} </span>
|
<span class="js-referenced-users-count">{{ referencedUsers.length }}</span>
|
||||||
</strong>
|
</strong>
|
||||||
people to the discussion. Proceed with caution.
|
people to the discussion. Proceed with caution.
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -17,6 +17,16 @@ export default {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
lineContent: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
canSuggest: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
mdTable() {
|
mdTable() {
|
||||||
|
@ -27,6 +37,9 @@ export default {
|
||||||
'| cell | cell |',
|
'| cell | cell |',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
},
|
},
|
||||||
|
mdSuggestion() {
|
||||||
|
return ['```suggestion', `{text}`, '```'].join('\n');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
$(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
|
$(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
|
||||||
|
@ -119,6 +132,16 @@ export default {
|
||||||
:button-title="__('Add a table')"
|
:button-title="__('Add a table')"
|
||||||
icon="table"
|
icon="table"
|
||||||
/>
|
/>
|
||||||
|
<toolbar-button
|
||||||
|
v-if="canSuggest"
|
||||||
|
:tag="mdSuggestion"
|
||||||
|
:prepend="true"
|
||||||
|
:button-title="__('Insert suggestion')"
|
||||||
|
:cursor-offset="4"
|
||||||
|
:tag-content="lineContent"
|
||||||
|
icon="doc-code"
|
||||||
|
class="qa-suggestion-btn"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-gl-tooltip
|
v-gl-tooltip
|
||||||
aria-label="Go full screen"
|
aria-label="Go full screen"
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
<script>
|
||||||
|
import SuggestionDiffHeader from './suggestion_diff_header.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
SuggestionDiffHeader,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
newLines: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fromContent: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
fromLine: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
suggestion: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
applySuggestion(callback) {
|
||||||
|
this.$emit('apply', { suggestionId: this.suggestion.id, callback });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<suggestion-diff-header
|
||||||
|
class="qa-suggestion-diff-header"
|
||||||
|
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
|
||||||
|
:is-applied="suggestion.applied"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
|
@apply="applySuggestion"
|
||||||
|
/>
|
||||||
|
<table class="mb-3 md-suggestion-diff">
|
||||||
|
<tbody>
|
||||||
|
<!-- Old Line -->
|
||||||
|
<tr class="line_holder old">
|
||||||
|
<td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td>
|
||||||
|
<td class="diff-line-num new_line old"></td>
|
||||||
|
<td class="line_content old">
|
||||||
|
<span>{{ fromContent }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- New Line(s) -->
|
||||||
|
<tr v-for="(line, key) of newLines" :key="key" class="line_holder new">
|
||||||
|
<td class="diff-line-num old_line new"></td>
|
||||||
|
<td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td>
|
||||||
|
<td class="line_content new">
|
||||||
|
<span>{{ line.content }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,60 @@
|
||||||
|
<script>
|
||||||
|
import Icon from '~/vue_shared/components/icon.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
props: {
|
||||||
|
canApply: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isApplied: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isAppliedSuccessfully: false,
|
||||||
|
isApplying: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
applySuggestion() {
|
||||||
|
if (!this.canApply) return;
|
||||||
|
this.isApplying = true;
|
||||||
|
this.$emit('apply', this.applySuggestionCallback);
|
||||||
|
},
|
||||||
|
applySuggestionCallback() {
|
||||||
|
this.isApplying = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="md-suggestion-header border-bottom-0 mt-2">
|
||||||
|
<div class="qa-suggestion-diff-header font-weight-bold">
|
||||||
|
{{ __('Suggested change') }}
|
||||||
|
<a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')">
|
||||||
|
<icon name="question-o" css-classes="link-highlight" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canApply"
|
||||||
|
type="button"
|
||||||
|
class="btn qa-apply-btn"
|
||||||
|
:disabled="isApplying"
|
||||||
|
@click="applySuggestion"
|
||||||
|
>
|
||||||
|
{{ __('Apply suggestion') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,136 @@
|
||||||
|
<script>
|
||||||
|
import Vue from 'vue';
|
||||||
|
import SuggestionDiff from './suggestion_diff.vue';
|
||||||
|
import Flash from '~/flash';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { SuggestionDiff },
|
||||||
|
props: {
|
||||||
|
fromLine: {
|
||||||
|
type: Number,
|
||||||
|
required: false,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
fromContent: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
lineType: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
type: Array,
|
||||||
|
required: false,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
noteHtml: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
helpPagePath: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isRendered: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
suggestions() {
|
||||||
|
this.reset();
|
||||||
|
},
|
||||||
|
noteHtml() {
|
||||||
|
this.reset();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.renderSuggestions();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
renderSuggestions() {
|
||||||
|
// swaps out suggestion(s) markdown with rich diff components
|
||||||
|
// (while still keeping non-suggestion markdown in place)
|
||||||
|
|
||||||
|
if (!this.noteHtml) return;
|
||||||
|
const { container } = this.$refs;
|
||||||
|
const suggestionElements = container.querySelectorAll('.js-render-suggestion');
|
||||||
|
|
||||||
|
if (this.lineType === 'old') {
|
||||||
|
Flash('Unable to apply suggestions to a deleted line.', 'alert', this.$el);
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestionElements.forEach((suggestionEl, i) => {
|
||||||
|
const suggestionParentEl = suggestionEl.parentElement;
|
||||||
|
const newLines = this.extractNewLines(suggestionParentEl);
|
||||||
|
const diffComponent = this.generateDiff(newLines, i);
|
||||||
|
diffComponent.$mount(suggestionParentEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isRendered = true;
|
||||||
|
},
|
||||||
|
extractNewLines(suggestionEl) {
|
||||||
|
// extracts the suggested lines from the markdown
|
||||||
|
// calculates a line number for each line
|
||||||
|
|
||||||
|
const FIRST_CHAR_REGEX = /^(\+|-)/;
|
||||||
|
const newLines = suggestionEl.querySelectorAll('.line');
|
||||||
|
const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine;
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
newLines.forEach((line, i) => {
|
||||||
|
const content = `${line.innerText.replace(FIRST_CHAR_REGEX, '')}\n`;
|
||||||
|
const lineNumber = fromLine + i;
|
||||||
|
lines.push({ content, lineNumber });
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
},
|
||||||
|
generateDiff(newLines, suggestionIndex) {
|
||||||
|
// generates the diff <suggestion-diff /> component
|
||||||
|
// all `suggestion` markdown will be swapped out by this component
|
||||||
|
|
||||||
|
const { suggestions, disabled, helpPagePath } = this;
|
||||||
|
const suggestion =
|
||||||
|
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
|
||||||
|
const fromContent = suggestion.from_content || this.fromContent;
|
||||||
|
const fromLine = suggestion.from_line || this.fromLine;
|
||||||
|
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
|
||||||
|
const suggestionDiff = new SuggestionDiffComponent({
|
||||||
|
propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath },
|
||||||
|
});
|
||||||
|
|
||||||
|
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
|
||||||
|
this.$emit('apply', { suggestionId, callback, flashContainer: this.$el });
|
||||||
|
});
|
||||||
|
|
||||||
|
return suggestionDiff;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
// resets the container HTML (replaces it with the updated noteHTML)
|
||||||
|
// calls `renderSuggestions` once the updated noteHTML is added to the DOM
|
||||||
|
|
||||||
|
this.$refs.container.innerHTML = this.noteHtml;
|
||||||
|
this.isRendered = false;
|
||||||
|
this.renderSuggestions();
|
||||||
|
this.$nextTick(() => this.renderSuggestions());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flash-container mt-3"></div>
|
||||||
|
<div v-show="isRendered" ref="container" v-html="noteHtml"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -37,6 +37,16 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
tagContent: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
cursorOffset: {
|
||||||
|
type: Number,
|
||||||
|
required: false,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -45,8 +55,10 @@ export default {
|
||||||
<button
|
<button
|
||||||
v-gl-tooltip
|
v-gl-tooltip
|
||||||
:data-md-tag="tag"
|
:data-md-tag="tag"
|
||||||
|
:data-md-cursor-offset="cursorOffset"
|
||||||
:data-md-select="tagSelect"
|
:data-md-select="tagSelect"
|
||||||
:data-md-block="tagBlock"
|
:data-md-block="tagBlock"
|
||||||
|
:data-md-tag-content="tagContent"
|
||||||
:data-md-prepend="prepend"
|
:data-md-prepend="prepend"
|
||||||
:title="buttonTitle"
|
:title="buttonTitle"
|
||||||
:aria-label="buttonTitle"
|
:aria-label="buttonTitle"
|
||||||
|
|
|
@ -277,6 +277,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-suggestion-diff {
|
||||||
|
display: table !important;
|
||||||
|
border: 1px solid $border-color !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-suggestion-header {
|
||||||
|
height: $suggestion-header-height;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: $gray-light;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
padding: $gl-padding;
|
||||||
|
border-radius: $border-radius-default $border-radius-default 0 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(xs) {
|
@include media-breakpoint-down(xs) {
|
||||||
.atwho-view-ul {
|
.atwho-view-ul {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
|
|
|
@ -252,6 +252,7 @@ $browserScrollbarSize: 10px;
|
||||||
* Misc
|
* Misc
|
||||||
*/
|
*/
|
||||||
$header-height: 40px;
|
$header-height: 40px;
|
||||||
|
$suggestion-header-height: 46px;
|
||||||
$ide-statusbar-height: 25px;
|
$ide-statusbar-height: 25px;
|
||||||
$fixed-layout-width: 1280px;
|
$fixed-layout-width: 1280px;
|
||||||
$limited-layout-width: 990px;
|
$limited-layout-width: 990px;
|
||||||
|
|
|
@ -12,7 +12,7 @@ module PreviewMarkdown
|
||||||
when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
|
when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
|
||||||
when 'snippets' then { skip_project_check: true }
|
when 'snippets' then { skip_project_check: true }
|
||||||
when 'groups' then { group: group }
|
when 'groups' then { group: group }
|
||||||
when 'projects' then { issuable_state_filter_enabled: true }
|
when 'projects' then projects_filter_params
|
||||||
else {}
|
else {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -22,9 +22,17 @@ module PreviewMarkdown
|
||||||
body: view_context.markdown(result[:text], markdown_params),
|
body: view_context.markdown(result[:text], markdown_params),
|
||||||
references: {
|
references: {
|
||||||
users: result[:users],
|
users: result[:users],
|
||||||
|
suggestions: result[:suggestions],
|
||||||
commands: view_context.markdown(result[:commands])
|
commands: view_context.markdown(result[:commands])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def projects_filter_params
|
||||||
|
{
|
||||||
|
issuable_state_filter_enabled: true,
|
||||||
|
suggestions_filter_enabled: params[:preview_suggestions].present?
|
||||||
|
}
|
||||||
|
end
|
||||||
# rubocop:enable Gitlab/ModuleWithInstanceVariables
|
# rubocop:enable Gitlab/ModuleWithInstanceVariables
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,6 +26,10 @@ module Noteable
|
||||||
DiscussionNote.noteable_types.include?(base_class_name)
|
DiscussionNote.noteable_types.include?(base_class_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def supports_suggestion?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def discussions_rendered_on_frontend?
|
def discussions_rendered_on_frontend?
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
|
@ -66,10 +66,23 @@ class DiffNote < Note
|
||||||
self.original_position.diff_refs == diff_refs
|
self.original_position.diff_refs == diff_refs
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def supports_suggestion?
|
||||||
|
return false unless noteable.supports_suggestion? && on_text?
|
||||||
|
# We don't want to trigger side-effects of `diff_file` call.
|
||||||
|
return false unless file = fetch_diff_file
|
||||||
|
return false unless line = file.line_for_position(self.original_position)
|
||||||
|
|
||||||
|
line&.suggestible?
|
||||||
|
end
|
||||||
|
|
||||||
def discussion_first_note?
|
def discussion_first_note?
|
||||||
self == discussion.first_note
|
self == discussion.first_note
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def banzai_render_context(field)
|
||||||
|
super.merge(suggestions_filter_enabled: supports_suggestion?)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def enqueue_diff_file_creation_job
|
def enqueue_diff_file_creation_job
|
||||||
|
|
|
@ -363,6 +363,11 @@ class MergeRequest < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def supports_suggestion?
|
||||||
|
# Should be `true` when removing the FF.
|
||||||
|
Suggestion.feature_enabled?
|
||||||
|
end
|
||||||
|
|
||||||
# Calls `MergeWorker` to proceed with the merge process and
|
# Calls `MergeWorker` to proceed with the merge process and
|
||||||
# updates `merge_jid` with the MergeWorker#jid.
|
# updates `merge_jid` with the MergeWorker#jid.
|
||||||
# This helps tracking enqueued and ongoing merge jobs.
|
# This helps tracking enqueued and ongoing merge jobs.
|
||||||
|
|
|
@ -69,6 +69,12 @@ class Note < ActiveRecord::Base
|
||||||
belongs_to :last_edited_by, class_name: 'User'
|
belongs_to :last_edited_by, class_name: 'User'
|
||||||
|
|
||||||
has_many :todos
|
has_many :todos
|
||||||
|
|
||||||
|
# The delete_all definition is required here in order
|
||||||
|
# to generate the correct DELETE sql for
|
||||||
|
# suggestions.delete_all calls
|
||||||
|
has_many :suggestions, -> { order(:relative_order) },
|
||||||
|
inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
|
||||||
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
|
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
|
||||||
has_one :system_note_metadata
|
has_one :system_note_metadata
|
||||||
has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id
|
has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id
|
||||||
|
@ -110,7 +116,7 @@ class Note < ActiveRecord::Base
|
||||||
scope :inc_author, -> { includes(:author) }
|
scope :inc_author, -> { includes(:author) }
|
||||||
scope :inc_relations_for_view, -> do
|
scope :inc_relations_for_view, -> do
|
||||||
includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
|
includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
|
||||||
:system_note_metadata, :note_diff_file)
|
:system_note_metadata, :note_diff_file, :suggestions)
|
||||||
end
|
end
|
||||||
|
|
||||||
scope :with_notes_filter, -> (notes_filter) do
|
scope :with_notes_filter, -> (notes_filter) do
|
||||||
|
@ -226,6 +232,10 @@ class Note < ActiveRecord::Base
|
||||||
Gitlab::HookData::NoteBuilder.new(self).build
|
Gitlab::HookData::NoteBuilder.new(self).build
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def supports_suggestion?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def for_commit?
|
def for_commit?
|
||||||
noteable_type == "Commit"
|
noteable_type == "Commit"
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Suggestion < ApplicationRecord
|
||||||
|
FEATURE_FLAG = :diff_suggestions
|
||||||
|
|
||||||
|
belongs_to :note, inverse_of: :suggestions
|
||||||
|
validates :note, presence: true
|
||||||
|
validates :commit_id, presence: true, if: :applied?
|
||||||
|
|
||||||
|
delegate :original_position, :position, :diff_file,
|
||||||
|
:noteable, to: :note
|
||||||
|
|
||||||
|
def self.feature_enabled?
|
||||||
|
Feature.enabled?(FEATURE_FLAG)
|
||||||
|
end
|
||||||
|
|
||||||
|
def project
|
||||||
|
noteable.source_project
|
||||||
|
end
|
||||||
|
|
||||||
|
def branch
|
||||||
|
noteable.source_branch
|
||||||
|
end
|
||||||
|
|
||||||
|
# For now, suggestions only serve as a way to send patches that
|
||||||
|
# will change a single line (being able to apply multiple in the same place),
|
||||||
|
# which explains `from_line` and `to_line` being the same line.
|
||||||
|
# We'll iterate on that in https://gitlab.com/gitlab-org/gitlab-ce/issues/53310
|
||||||
|
# when allowing multi-line suggestions.
|
||||||
|
def from_line
|
||||||
|
position.new_line
|
||||||
|
end
|
||||||
|
alias_method :to_line, :from_line
|
||||||
|
|
||||||
|
def from_original_line
|
||||||
|
original_position.new_line
|
||||||
|
end
|
||||||
|
alias_method :to_original_line, :from_original_line
|
||||||
|
|
||||||
|
# `from_line_index` and `to_line_index` represents diff/blob line numbers in
|
||||||
|
# index-like way (N-1).
|
||||||
|
def from_line_index
|
||||||
|
from_line - 1
|
||||||
|
end
|
||||||
|
alias_method :to_line_index, :from_line_index
|
||||||
|
|
||||||
|
def appliable?
|
||||||
|
return false unless note.supports_suggestion?
|
||||||
|
|
||||||
|
!applied? &&
|
||||||
|
noteable.opened? &&
|
||||||
|
different_content? &&
|
||||||
|
note.active?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def different_content?
|
||||||
|
from_content != to_content
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,11 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SuggestionPolicy < BasePolicy
|
||||||
|
delegate { @subject.project }
|
||||||
|
|
||||||
|
condition(:can_push_to_branch) do
|
||||||
|
Gitlab::UserAccess.new(@user, project: @subject.project).can_push_to_branch?(@subject.branch)
|
||||||
|
end
|
||||||
|
|
||||||
|
rule { can_push_to_branch }.enable :apply_suggestion
|
||||||
|
end
|
|
@ -11,4 +11,6 @@ class DiffLineEntity < Grape::Entity
|
||||||
expose :rich_text do |line|
|
expose :rich_text do |line|
|
||||||
ERB::Util.html_escape(line.rich_text || line.text)
|
ERB::Util.html_escape(line.rich_text || line.text)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
expose :suggestible?, as: :can_receive_suggestion
|
||||||
end
|
end
|
||||||
|
|
|
@ -238,6 +238,8 @@ class MergeRequestWidgetEntity < IssuableEntity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
expose :supports_suggestion?, as: :can_receive_suggestion
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
delegate :current_user, to: :request
|
delegate :current_user, to: :request
|
||||||
|
|
|
@ -36,6 +36,7 @@ class NoteEntity < API::Entities::Note
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
expose :suggestions, using: SuggestionEntity
|
||||||
expose :resolved?, as: :resolved
|
expose :resolved?, as: :resolved
|
||||||
expose :resolvable?, as: :resolvable
|
expose :resolvable?, as: :resolvable
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SuggestionEntity < API::Entities::Suggestion
|
||||||
|
include RequestAwareEntity
|
||||||
|
|
||||||
|
expose :current_user do
|
||||||
|
expose :can_apply do |suggestion|
|
||||||
|
Ability.allowed?(current_user, :apply_suggestion, suggestion)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def current_user
|
||||||
|
request.current_user
|
||||||
|
end
|
||||||
|
end
|
|
@ -36,6 +36,7 @@ module Notes
|
||||||
if !only_commands && note.save
|
if !only_commands && note.save
|
||||||
todo_service.new_note(note, current_user)
|
todo_service.new_note(note, current_user)
|
||||||
clear_noteable_diffs_cache(note)
|
clear_noteable_diffs_cache(note)
|
||||||
|
Suggestions::CreateService.new(note).execute
|
||||||
end
|
end
|
||||||
|
|
||||||
if command_params.present?
|
if command_params.present?
|
||||||
|
|
|
@ -14,6 +14,17 @@ module Notes
|
||||||
TodoService.new.update_note(note, current_user, old_mentioned_users)
|
TodoService.new.update_note(note, current_user, old_mentioned_users)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if note.supports_suggestion?
|
||||||
|
Suggestion.transaction do
|
||||||
|
note.suggestions.delete_all
|
||||||
|
Suggestions::CreateService.new(note).execute
|
||||||
|
end
|
||||||
|
|
||||||
|
# We need to refresh the previous suggestions call cache
|
||||||
|
# in order to get the new records.
|
||||||
|
note.reload
|
||||||
|
end
|
||||||
|
|
||||||
note
|
note
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,10 +4,12 @@ class PreviewMarkdownService < BaseService
|
||||||
def execute
|
def execute
|
||||||
text, commands = explain_quick_actions(params[:text])
|
text, commands = explain_quick_actions(params[:text])
|
||||||
users = find_user_references(text)
|
users = find_user_references(text)
|
||||||
|
suggestions = find_suggestions(text)
|
||||||
|
|
||||||
success(
|
success(
|
||||||
text: text,
|
text: text,
|
||||||
users: users,
|
users: users,
|
||||||
|
suggestions: suggestions,
|
||||||
commands: commands.join(' '),
|
commands: commands.join(' '),
|
||||||
markdown_engine: markdown_engine
|
markdown_engine: markdown_engine
|
||||||
)
|
)
|
||||||
|
@ -28,6 +30,12 @@ class PreviewMarkdownService < BaseService
|
||||||
extractor.users.map(&:username)
|
extractor.users.map(&:username)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def find_suggestions(text)
|
||||||
|
return [] unless params[:preview_suggestions]
|
||||||
|
|
||||||
|
Banzai::SuggestionsParser.parse(text)
|
||||||
|
end
|
||||||
|
|
||||||
def find_commands_target
|
def find_commands_target
|
||||||
QuickActions::TargetService
|
QuickActions::TargetService
|
||||||
.new(project, current_user)
|
.new(project, current_user)
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Suggestions
|
||||||
|
class ApplyService < ::BaseService
|
||||||
|
def initialize(current_user)
|
||||||
|
@current_user = current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute(suggestion)
|
||||||
|
unless suggestion.appliable?
|
||||||
|
return error('Suggestion is not appliable')
|
||||||
|
end
|
||||||
|
|
||||||
|
params = file_update_params(suggestion)
|
||||||
|
result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute
|
||||||
|
|
||||||
|
if result[:status] == :success
|
||||||
|
suggestion.update(commit_id: result[:result], applied: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def file_update_params(suggestion)
|
||||||
|
diff_file = suggestion.diff_file
|
||||||
|
|
||||||
|
file_path = diff_file.file_path
|
||||||
|
branch_name = suggestion.noteable.source_branch
|
||||||
|
file_content = new_file_content(suggestion)
|
||||||
|
commit_message = "Apply suggestion to #{file_path}"
|
||||||
|
|
||||||
|
{
|
||||||
|
file_path: file_path,
|
||||||
|
branch_name: branch_name,
|
||||||
|
start_branch: branch_name,
|
||||||
|
commit_message: commit_message,
|
||||||
|
file_content: file_content
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_file_content(suggestion)
|
||||||
|
range = suggestion.from_line_index..suggestion.to_line_index
|
||||||
|
blob = suggestion.diff_file.new_blob
|
||||||
|
|
||||||
|
blob.load_all_data!
|
||||||
|
content = blob.data.lines
|
||||||
|
content[range] = suggestion.to_content
|
||||||
|
|
||||||
|
content.join
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,56 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Suggestions
|
||||||
|
class CreateService
|
||||||
|
def initialize(note)
|
||||||
|
@note = note
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute
|
||||||
|
return unless @note.supports_suggestion?
|
||||||
|
|
||||||
|
suggestions = Banzai::SuggestionsParser.parse(@note.note)
|
||||||
|
|
||||||
|
# For single line suggestion we're only looking forward to
|
||||||
|
# change the line receiving the comment. Though, in
|
||||||
|
# https://gitlab.com/gitlab-org/gitlab-ce/issues/53310
|
||||||
|
# we'll introduce a ```suggestion:L<x>-<y>, so this will
|
||||||
|
# slightly change.
|
||||||
|
comment_line = @note.position.new_line
|
||||||
|
|
||||||
|
rows =
|
||||||
|
suggestions.map.with_index do |suggestion, index|
|
||||||
|
from_content = changing_lines(comment_line, comment_line)
|
||||||
|
|
||||||
|
# The parsed suggestion doesn't have information about the correct
|
||||||
|
# ending characters (we may have a line break, or not), so we take
|
||||||
|
# this information from the last line being changed (last
|
||||||
|
# characters).
|
||||||
|
endline_chars = line_break_chars(from_content.lines.last)
|
||||||
|
to_content = "#{suggestion}#{endline_chars}"
|
||||||
|
|
||||||
|
{
|
||||||
|
note_id: @note.id,
|
||||||
|
from_content: from_content,
|
||||||
|
to_content: to_content,
|
||||||
|
relative_order: index
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
rows.in_groups_of(100, false) do |rows|
|
||||||
|
Gitlab::Database.bulk_insert('suggestions', rows)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def changing_lines(from_line, to_line)
|
||||||
|
@note.diff_file.new_blob_lines_between(from_line, to_line).join
|
||||||
|
end
|
||||||
|
|
||||||
|
def line_break_chars(line)
|
||||||
|
match = /\r\n|\r|\n/.match(line)
|
||||||
|
match[0] if match
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -67,6 +67,7 @@
|
||||||
noteable_data: serialize_issuable(@merge_request),
|
noteable_data: serialize_issuable(@merge_request),
|
||||||
noteable_type: 'MergeRequest',
|
noteable_type: 'MergeRequest',
|
||||||
target_type: 'merge_request',
|
target_type: 'merge_request',
|
||||||
|
help_page_path: nil,
|
||||||
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} }
|
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} }
|
||||||
|
|
||||||
#commits.commits.tab-pane
|
#commits.commits.tab-pane
|
||||||
|
@ -76,6 +77,7 @@
|
||||||
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
|
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
|
||||||
#js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?,
|
#js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?,
|
||||||
endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
|
endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
|
||||||
|
help_page_path: nil,
|
||||||
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json,
|
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json,
|
||||||
project_path: project_path(@merge_request.project),
|
project_path: project_path(@merge_request.project),
|
||||||
changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg') } }
|
changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg') } }
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Add ability to render suggestions
|
||||||
|
merge_request: 23147
|
||||||
|
author:
|
||||||
|
type: added
|
|
@ -0,0 +1,20 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateSuggestions < ActiveRecord::Migration
|
||||||
|
DOWNTIME = false
|
||||||
|
|
||||||
|
def change
|
||||||
|
create_table :suggestions, id: :bigserial do |t|
|
||||||
|
t.references :note, foreign_key: { on_delete: :cascade }, null: false
|
||||||
|
t.integer :relative_order, null: false, limit: 2
|
||||||
|
t.boolean :applied, null: false, default: false
|
||||||
|
t.string :commit_id
|
||||||
|
t.text :from_content, null: false
|
||||||
|
t.text :to_content, null: false
|
||||||
|
|
||||||
|
t.index [:note_id, :relative_order],
|
||||||
|
name: 'index_suggestions_on_note_id_and_relative_order',
|
||||||
|
unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
11
db/schema.rb
11
db/schema.rb
|
@ -1956,6 +1956,16 @@ ActiveRecord::Schema.define(version: 20181204154019) do
|
||||||
t.index ["subscribable_id", "subscribable_type", "user_id", "project_id"], name: "index_subscriptions_on_subscribable_and_user_id_and_project_id", unique: true, using: :btree
|
t.index ["subscribable_id", "subscribable_type", "user_id", "project_id"], name: "index_subscriptions_on_subscribable_and_user_id_and_project_id", unique: true, using: :btree
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "suggestions", id: :bigserial, force: :cascade do |t|
|
||||||
|
t.integer "note_id", null: false
|
||||||
|
t.integer "relative_order", limit: 2, null: false
|
||||||
|
t.boolean "applied", default: false, null: false
|
||||||
|
t.string "commit_id"
|
||||||
|
t.text "from_content", null: false
|
||||||
|
t.text "to_content", null: false
|
||||||
|
t.index ["note_id", "relative_order"], name: "index_suggestions_on_note_id_and_relative_order", unique: true, using: :btree
|
||||||
|
end
|
||||||
|
|
||||||
create_table "system_note_metadata", force: :cascade do |t|
|
create_table "system_note_metadata", force: :cascade do |t|
|
||||||
t.integer "note_id", null: false
|
t.integer "note_id", null: false
|
||||||
t.integer "commit_count"
|
t.integer "commit_count"
|
||||||
|
@ -2432,6 +2442,7 @@ ActiveRecord::Schema.define(version: 20181204154019) do
|
||||||
add_foreign_key "services", "projects", name: "fk_71cce407f9", on_delete: :cascade
|
add_foreign_key "services", "projects", name: "fk_71cce407f9", on_delete: :cascade
|
||||||
add_foreign_key "snippets", "projects", name: "fk_be41fd4bb7", on_delete: :cascade
|
add_foreign_key "snippets", "projects", name: "fk_be41fd4bb7", on_delete: :cascade
|
||||||
add_foreign_key "subscriptions", "projects", on_delete: :cascade
|
add_foreign_key "subscriptions", "projects", on_delete: :cascade
|
||||||
|
add_foreign_key "suggestions", "notes", on_delete: :cascade
|
||||||
add_foreign_key "system_note_metadata", "notes", name: "fk_d83a918cb1", on_delete: :cascade
|
add_foreign_key "system_note_metadata", "notes", name: "fk_d83a918cb1", on_delete: :cascade
|
||||||
add_foreign_key "term_agreements", "application_setting_terms", column: "term_id"
|
add_foreign_key "term_agreements", "application_setting_terms", column: "term_id"
|
||||||
add_foreign_key "term_agreements", "users", on_delete: :cascade
|
add_foreign_key "term_agreements", "users", on_delete: :cascade
|
||||||
|
|
|
@ -149,6 +149,7 @@ module API
|
||||||
mount ::API::Snippets
|
mount ::API::Snippets
|
||||||
mount ::API::Submodules
|
mount ::API::Submodules
|
||||||
mount ::API::Subscriptions
|
mount ::API::Subscriptions
|
||||||
|
mount ::API::Suggestions
|
||||||
mount ::API::SystemHooks
|
mount ::API::SystemHooks
|
||||||
mount ::API::Tags
|
mount ::API::Tags
|
||||||
mount ::API::Templates
|
mount ::API::Templates
|
||||||
|
|
|
@ -1495,5 +1495,17 @@ module API
|
||||||
expose :label, using: Entities::LabelBasic
|
expose :label, using: Entities::LabelBasic
|
||||||
expose :action
|
expose :action
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class Suggestion < Grape::Entity
|
||||||
|
expose :id
|
||||||
|
expose :from_original_line
|
||||||
|
expose :to_original_line
|
||||||
|
expose :from_line
|
||||||
|
expose :to_line
|
||||||
|
expose :appliable?, as: :appliable
|
||||||
|
expose :applied
|
||||||
|
expose :from_content
|
||||||
|
expose :to_content
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module API
|
||||||
|
class Suggestions < Grape::API
|
||||||
|
before { authenticate! }
|
||||||
|
|
||||||
|
resource :suggestions do
|
||||||
|
desc 'Apply suggestion patch in the Merge Request it was created' do
|
||||||
|
success Entities::Suggestion
|
||||||
|
end
|
||||||
|
params do
|
||||||
|
requires :id, type: String, desc: 'The suggestion ID'
|
||||||
|
end
|
||||||
|
put ':id/apply' do
|
||||||
|
suggestion = Suggestion.find_by_id(params[:id])
|
||||||
|
|
||||||
|
not_found! unless suggestion
|
||||||
|
authorize! :apply_suggestion, suggestion
|
||||||
|
|
||||||
|
result = ::Suggestions::ApplyService.new(current_user).execute(suggestion)
|
||||||
|
|
||||||
|
if result[:status] == :success
|
||||||
|
present suggestion, with: Entities::Suggestion, current_user: current_user
|
||||||
|
else
|
||||||
|
http_status = result[:http_status] || 400
|
||||||
|
render_api_error!(result[:message], http_status)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,25 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Banzai
|
||||||
|
module Filter
|
||||||
|
class SuggestionFilter < HTML::Pipeline::Filter
|
||||||
|
# Class used for tagging elements that should be rendered
|
||||||
|
TAG_CLASS = 'js-render-suggestion'.freeze
|
||||||
|
|
||||||
|
def call
|
||||||
|
return doc unless Suggestion.feature_enabled?
|
||||||
|
return doc unless suggestions_filter_enabled?
|
||||||
|
|
||||||
|
doc.search('pre.suggestion > code').each do |node|
|
||||||
|
node.add_class(TAG_CLASS)
|
||||||
|
end
|
||||||
|
|
||||||
|
doc
|
||||||
|
end
|
||||||
|
|
||||||
|
def suggestions_filter_enabled?
|
||||||
|
context[:suggestions_filter_enabled]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -69,7 +69,7 @@ module Banzai
|
||||||
end
|
end
|
||||||
|
|
||||||
def use_rouge?(language)
|
def use_rouge?(language)
|
||||||
%w(math mermaid plantuml).exclude?(language)
|
%w(math mermaid plantuml suggestion).exclude?(language)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -29,6 +29,7 @@ module Banzai
|
||||||
Filter::TableOfContentsFilter,
|
Filter::TableOfContentsFilter,
|
||||||
Filter::AutolinkFilter,
|
Filter::AutolinkFilter,
|
||||||
Filter::ExternalLinkFilter,
|
Filter::ExternalLinkFilter,
|
||||||
|
Filter::SuggestionFilter,
|
||||||
|
|
||||||
*reference_filters,
|
*reference_filters,
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,8 @@ module Banzai
|
||||||
[
|
[
|
||||||
Filter::RedactorFilter,
|
Filter::RedactorFilter,
|
||||||
Filter::RelativeLinkFilter,
|
Filter::RelativeLinkFilter,
|
||||||
Filter::IssuableStateFilter
|
Filter::IssuableStateFilter,
|
||||||
|
Filter::SuggestionFilter
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Banzai
|
||||||
|
module SuggestionsParser
|
||||||
|
# Returns the content of each suggestion code block.
|
||||||
|
#
|
||||||
|
def self.parse(text)
|
||||||
|
html = Banzai.render(text, project: nil, no_original_data: true)
|
||||||
|
doc = Nokogiri::HTML(html)
|
||||||
|
|
||||||
|
doc.search('pre.suggestion').map { |node| node.text }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -122,6 +122,16 @@ module Gitlab
|
||||||
old_blob_lazy&.itself
|
old_blob_lazy&.itself
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def new_blob_lines_between(from_line, to_line)
|
||||||
|
return [] unless new_blob
|
||||||
|
|
||||||
|
from_index = from_line - 1
|
||||||
|
to_index = to_line - 1
|
||||||
|
|
||||||
|
new_blob.load_all_data!
|
||||||
|
new_blob.data.lines[from_index..to_index]
|
||||||
|
end
|
||||||
|
|
||||||
def content_sha
|
def content_sha
|
||||||
new_content_sha || old_content_sha
|
new_content_sha || old_content_sha
|
||||||
end
|
end
|
||||||
|
|
|
@ -73,6 +73,10 @@ module Gitlab
|
||||||
!meta?
|
!meta?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def suggestible?
|
||||||
|
!removed?
|
||||||
|
end
|
||||||
|
|
||||||
def rich_text
|
def rich_text
|
||||||
@parent_file.try(:highlight_lines!) if @parent_file && !@rich_text
|
@parent_file.try(:highlight_lines!) if @parent_file && !@rich_text
|
||||||
|
|
||||||
|
|
|
@ -85,6 +85,8 @@ module Gitlab
|
||||||
releases: count(Release),
|
releases: count(Release),
|
||||||
remote_mirrors: count(RemoteMirror),
|
remote_mirrors: count(RemoteMirror),
|
||||||
snippets: count(Snippet),
|
snippets: count(Snippet),
|
||||||
|
suggestions: count(Suggestion),
|
||||||
|
todos: count(Todo),
|
||||||
uploads: count(Upload),
|
uploads: count(Upload),
|
||||||
web_hooks: count(WebHook)
|
web_hooks: count(WebHook)
|
||||||
}.merge(services_usage).merge(approximate_counts)
|
}.merge(services_usage).merge(approximate_counts)
|
||||||
|
|
|
@ -657,6 +657,12 @@ msgstr ""
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Applied"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Apply suggestion"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Apr"
|
msgid "Apr"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -3597,6 +3603,9 @@ msgstr ""
|
||||||
msgid "Input your repository URL"
|
msgid "Input your repository URL"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Insert suggestion"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Install GitLab Runner"
|
msgid "Install GitLab Runner"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -6080,6 +6089,9 @@ msgstr ""
|
||||||
msgid "Something went wrong when toggling the button"
|
msgid "Something went wrong when toggling the button"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Something went wrong while applying the suggestion. Please try again."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Something went wrong while closing the %{issuable}. Please try again later"
|
msgid "Something went wrong while closing the %{issuable}. Please try again later"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -6347,6 +6359,9 @@ msgstr ""
|
||||||
msgid "Subscribed"
|
msgid "Subscribed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Suggested change"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Switch branch/tag"
|
msgid "Switch branch/tag"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
|
@ -54,7 +54,8 @@ describe 'Database schema' do
|
||||||
user_agent_details: %w[subject_id],
|
user_agent_details: %w[subject_id],
|
||||||
users: %w[color_scheme_id created_by_id theme_id],
|
users: %w[color_scheme_id created_by_id theme_id],
|
||||||
users_star_projects: %w[user_id],
|
users_star_projects: %w[user_id],
|
||||||
web_hooks: %w[service_id]
|
web_hooks: %w[service_id],
|
||||||
|
suggestions: %w[commit_id]
|
||||||
}.with_indifferent_access.freeze
|
}.with_indifferent_access.freeze
|
||||||
|
|
||||||
context 'for table' do
|
context 'for table' do
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :suggestion do
|
||||||
|
relative_order 0
|
||||||
|
association :note, factory: :diff_note_on_merge_request
|
||||||
|
from_content " vars = {\n"
|
||||||
|
to_content " vars = [\n"
|
||||||
|
|
||||||
|
trait :unappliable do
|
||||||
|
from_content "foo"
|
||||||
|
to_content "foo"
|
||||||
|
end
|
||||||
|
|
||||||
|
trait :applied do
|
||||||
|
applied true
|
||||||
|
commit_id { RepoHelpers.sample_commit.id }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,85 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe 'User comments on a diff', :js do
|
||||||
|
include MergeRequestDiffHelpers
|
||||||
|
include RepoHelpers
|
||||||
|
|
||||||
|
let(:project) { create(:project, :repository) }
|
||||||
|
let(:merge_request) do
|
||||||
|
create(:merge_request_with_diffs, source_project: project, target_project: project, source_branch: 'merge-test')
|
||||||
|
end
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
project.add_maintainer(user)
|
||||||
|
sign_in(user)
|
||||||
|
|
||||||
|
visit(diffs_project_merge_request_path(project, merge_request))
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'single suggestion note' do
|
||||||
|
it 'suggestion is presented' do
|
||||||
|
click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))
|
||||||
|
|
||||||
|
page.within('.js-discussion-note-form') do
|
||||||
|
fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
|
||||||
|
click_button('Comment')
|
||||||
|
end
|
||||||
|
|
||||||
|
wait_for_requests
|
||||||
|
|
||||||
|
page.within('.diff-discussions') do
|
||||||
|
expect(page).to have_button('Apply suggestion')
|
||||||
|
expect(page).to have_content('Suggested change')
|
||||||
|
expect(page).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git')
|
||||||
|
expect(page).to have_content('# change to a comment')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'suggestion is appliable' do
|
||||||
|
click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))
|
||||||
|
|
||||||
|
page.within('.js-discussion-note-form') do
|
||||||
|
fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
|
||||||
|
click_button('Comment')
|
||||||
|
end
|
||||||
|
|
||||||
|
wait_for_requests
|
||||||
|
|
||||||
|
page.within('.diff-discussions') do
|
||||||
|
expect(page).not_to have_content('Applied')
|
||||||
|
|
||||||
|
click_button('Apply suggestion')
|
||||||
|
wait_for_requests
|
||||||
|
|
||||||
|
expect(page).to have_content('Applied')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'multiple suggestions in a single note' do
|
||||||
|
it 'suggestions are presented' do
|
||||||
|
click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))
|
||||||
|
|
||||||
|
page.within('.js-discussion-note-form') do
|
||||||
|
fill_in('note_note', with: "```suggestion\n# change to a comment\n```\n```suggestion\n# or that\n```")
|
||||||
|
click_button('Comment')
|
||||||
|
end
|
||||||
|
|
||||||
|
wait_for_requests
|
||||||
|
|
||||||
|
page.within('.diff-discussions') do
|
||||||
|
suggestion_1 = page.all(:css, '.md-suggestion-diff')[0]
|
||||||
|
suggestion_2 = page.all(:css, '.md-suggestion-diff')[1]
|
||||||
|
|
||||||
|
expect(suggestion_1).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git')
|
||||||
|
expect(suggestion_1).to have_content('# change to a comment')
|
||||||
|
|
||||||
|
expect(suggestion_2).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git')
|
||||||
|
expect(suggestion_2).to have_content('# or that')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -8,7 +8,8 @@
|
||||||
"new_line": { "type": ["integer", "null"] },
|
"new_line": { "type": ["integer", "null"] },
|
||||||
"text": { "type": ["string"] },
|
"text": { "type": ["string"] },
|
||||||
"rich_text": { "type": ["string"] },
|
"rich_text": { "type": ["string"] },
|
||||||
"meta_data": { "type": ["object", "null"] }
|
"meta_data": { "type": ["object", "null"] },
|
||||||
|
"can_receive_suggestion": { "type": "boolean" }
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|
|
@ -119,7 +119,8 @@
|
||||||
"can_push_to_source_branch": { "type": "boolean" },
|
"can_push_to_source_branch": { "type": "boolean" },
|
||||||
"rebase_path": { "type": ["string", "null"] },
|
"rebase_path": { "type": ["string", "null"] },
|
||||||
"squash": { "type": "boolean" },
|
"squash": { "type": "boolean" },
|
||||||
"test_reports_path": { "type": ["string", "null"] }
|
"test_reports_path": { "type": ["string", "null"] },
|
||||||
|
"can_receive_suggestion": { "type": "boolean" }
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ describe('DiffContent', () => {
|
||||||
current_user: {
|
current_user: {
|
||||||
can_create_note: false,
|
can_create_note: false,
|
||||||
},
|
},
|
||||||
|
preview_note_path: 'path/to/preview',
|
||||||
};
|
};
|
||||||
|
|
||||||
vm = mountComponentWithStore(Component, {
|
vm = mountComponentWithStore(Component, {
|
||||||
|
|
|
@ -487,8 +487,19 @@ export default {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
diff_discussion: true,
|
diff_discussion: true,
|
||||||
truncated_diff_lines:
|
truncated_diff_lines: [
|
||||||
|
{
|
||||||
|
text: 'line',
|
||||||
|
rich_text:
|
||||||
'<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="1">\n1\n</td>\n<td class="line_content new noteable_line"><span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n</td>\n</tr>\n<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="2">\n2\n</td>\n<td class="line_content new noteable_line"><span id="LC2" class="line" lang="plaintext"></span>\n</td>\n</tr>\n',
|
'<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="1">\n1\n</td>\n<td class="line_content new noteable_line"><span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n</td>\n</tr>\n<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="2">\n2\n</td>\n<td class="line_content new noteable_line"><span id="LC2" class="line" lang="plaintext"></span>\n</td>\n</tr>\n',
|
||||||
|
can_receive_suggestion: true,
|
||||||
|
line_code: '6f209374f7e565f771b95720abf46024c41d1885_1_1',
|
||||||
|
type: 'new',
|
||||||
|
old_line: null,
|
||||||
|
new_line: 1,
|
||||||
|
meta_data: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const imageDiffDiscussions = [
|
export const imageDiffDiscussions = [
|
||||||
|
|
|
@ -28,6 +28,7 @@ describe('Markdown field header component', () => {
|
||||||
'Add a numbered list',
|
'Add a numbered list',
|
||||||
'Add a task list',
|
'Add a task list',
|
||||||
'Add a table',
|
'Add a table',
|
||||||
|
'Insert suggestion',
|
||||||
'Go full screen',
|
'Go full screen',
|
||||||
];
|
];
|
||||||
const elements = vm.$el.querySelectorAll('.toolbar-btn');
|
const elements = vm.$el.querySelectorAll('.toolbar-btn');
|
||||||
|
@ -93,4 +94,18 @@ describe('Markdown field header component', () => {
|
||||||
'| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |',
|
'| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders suggestion template', () => {
|
||||||
|
vm.lineContent = 'Some content';
|
||||||
|
|
||||||
|
expect(vm.mdSuggestion).toEqual('```suggestion\n{text}\n```');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render suggestion button if `canSuggest` is set to false', () => {
|
||||||
|
vm.canSuggest = false;
|
||||||
|
|
||||||
|
Vue.nextTick(() => {
|
||||||
|
expect(vm.$el.querySelector('.qa-suggestion-btn')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
import Vue from 'vue';
|
||||||
|
import SuggestionDiffHeaderComponent from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
|
||||||
|
|
||||||
|
const MOCK_DATA = {
|
||||||
|
canApply: true,
|
||||||
|
isApplied: false,
|
||||||
|
helpPagePath: 'path_to_docs',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Suggestion Diff component', () => {
|
||||||
|
let vm;
|
||||||
|
|
||||||
|
function createComponent(propsData) {
|
||||||
|
const Component = Vue.extend(SuggestionDiffHeaderComponent);
|
||||||
|
|
||||||
|
return new Component({
|
||||||
|
propsData,
|
||||||
|
}).$mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(done => {
|
||||||
|
vm = createComponent(MOCK_DATA);
|
||||||
|
Vue.nextTick(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init', () => {
|
||||||
|
it('renders a suggestion header', () => {
|
||||||
|
const header = vm.$el.querySelector('.qa-suggestion-diff-header');
|
||||||
|
|
||||||
|
expect(header).not.toBeNull();
|
||||||
|
expect(header.innerHTML.includes('Suggested change')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an apply button', () => {
|
||||||
|
const applyBtn = vm.$el.querySelector('.qa-apply-btn');
|
||||||
|
|
||||||
|
expect(applyBtn).not.toBeNull();
|
||||||
|
expect(applyBtn.innerHTML.includes('Apply suggestion')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render an apply button if `canApply` is set to false', () => {
|
||||||
|
const props = Object.assign(MOCK_DATA, { canApply: false });
|
||||||
|
|
||||||
|
vm = createComponent(props);
|
||||||
|
|
||||||
|
expect(vm.$el.querySelector('.qa-apply-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applySuggestion', () => {
|
||||||
|
it('emits when the apply button is clicked', () => {
|
||||||
|
const props = Object.assign(MOCK_DATA, { canApply: true });
|
||||||
|
|
||||||
|
vm = createComponent(props);
|
||||||
|
spyOn(vm, '$emit');
|
||||||
|
vm.applySuggestion();
|
||||||
|
|
||||||
|
expect(vm.$emit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit when the canApply is set to false', () => {
|
||||||
|
spyOn(vm, '$emit');
|
||||||
|
vm.canApply = false;
|
||||||
|
vm.applySuggestion();
|
||||||
|
|
||||||
|
expect(vm.$emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,79 @@
|
||||||
|
import Vue from 'vue';
|
||||||
|
import SuggestionDiffComponent from '~/vue_shared/components/markdown/suggestion_diff.vue';
|
||||||
|
|
||||||
|
const MOCK_DATA = {
|
||||||
|
canApply: true,
|
||||||
|
newLines: [
|
||||||
|
{ content: 'Line 1\n', lineNumber: 1 },
|
||||||
|
{ content: 'Line 2\n', lineNumber: 2 },
|
||||||
|
{ content: 'Line 3\n', lineNumber: 3 },
|
||||||
|
],
|
||||||
|
fromLine: 1,
|
||||||
|
fromContent: 'Old content',
|
||||||
|
suggestion: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
helpPagePath: 'path_to_docs',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Suggestion Diff component', () => {
|
||||||
|
let vm;
|
||||||
|
|
||||||
|
beforeEach(done => {
|
||||||
|
const Component = Vue.extend(SuggestionDiffComponent);
|
||||||
|
|
||||||
|
vm = new Component({
|
||||||
|
propsData: MOCK_DATA,
|
||||||
|
}).$mount();
|
||||||
|
|
||||||
|
Vue.nextTick(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init', () => {
|
||||||
|
it('renders a suggestion header', () => {
|
||||||
|
expect(vm.$el.querySelector('.qa-suggestion-diff-header')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a diff table', () => {
|
||||||
|
expect(vm.$el.querySelector('table.md-suggestion-diff')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the oldLineNumber', () => {
|
||||||
|
const fromLine = vm.$el.querySelector('.qa-old-diff-line-number').innerHTML;
|
||||||
|
|
||||||
|
expect(parseInt(fromLine, 10)).toBe(vm.fromLine);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the oldLineContent', () => {
|
||||||
|
const fromContent = vm.$el.querySelector('.line_content.old').innerHTML;
|
||||||
|
|
||||||
|
expect(fromContent.includes(vm.fromContent)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the contents of newLines', () => {
|
||||||
|
const newLines = vm.$el.querySelectorAll('.line_holder.new');
|
||||||
|
|
||||||
|
newLines.forEach((line, i) => {
|
||||||
|
expect(newLines[i].innerHTML.includes(vm.newLines[i].content)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a line number for each line', () => {
|
||||||
|
const newLineNumbers = vm.$el.querySelectorAll('.qa-new-diff-line-number');
|
||||||
|
|
||||||
|
newLineNumbers.forEach((line, i) => {
|
||||||
|
expect(newLineNumbers[i].innerHTML.includes(vm.newLines[i].lineNumber)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applySuggestion', () => {
|
||||||
|
it('emits apply event when applySuggestion is called', () => {
|
||||||
|
const callback = () => {};
|
||||||
|
spyOn(vm, '$emit');
|
||||||
|
vm.applySuggestion(callback);
|
||||||
|
|
||||||
|
expect(vm.$emit).toHaveBeenCalledWith('apply', { suggestionId: vm.suggestion.id, callback });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,125 @@
|
||||||
|
import Vue from 'vue';
|
||||||
|
import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue';
|
||||||
|
|
||||||
|
const MOCK_DATA = {
|
||||||
|
fromLine: 1,
|
||||||
|
fromContent: 'Old content',
|
||||||
|
suggestions: [],
|
||||||
|
noteHtml: `
|
||||||
|
<div class="suggestion">
|
||||||
|
<div class="line">Suggestion 1</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestion">
|
||||||
|
<div class="line">Suggestion 2</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
isApplied: false,
|
||||||
|
helpPagePath: 'path_to_docs',
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateLine = content => {
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'line';
|
||||||
|
line.innerHTML = content;
|
||||||
|
|
||||||
|
return line;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMockLines = () => {
|
||||||
|
const line1 = generateLine('Line 1');
|
||||||
|
const line2 = generateLine('Line 2');
|
||||||
|
const line3 = generateLine('Line 3');
|
||||||
|
const container = document.createElement('div');
|
||||||
|
|
||||||
|
container.appendChild(line1);
|
||||||
|
container.appendChild(line2);
|
||||||
|
container.appendChild(line3);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Suggestion component', () => {
|
||||||
|
let vm;
|
||||||
|
let extractedLines;
|
||||||
|
let diffTable;
|
||||||
|
|
||||||
|
beforeEach(done => {
|
||||||
|
const Component = Vue.extend(SuggestionsComponent);
|
||||||
|
|
||||||
|
vm = new Component({
|
||||||
|
propsData: MOCK_DATA,
|
||||||
|
}).$mount();
|
||||||
|
|
||||||
|
extractedLines = vm.extractNewLines(generateMockLines());
|
||||||
|
diffTable = vm.generateDiff(extractedLines).$mount().$el;
|
||||||
|
|
||||||
|
spyOn(vm, 'renderSuggestions');
|
||||||
|
vm.renderSuggestions();
|
||||||
|
Vue.nextTick(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mounted', () => {
|
||||||
|
it('renders a flash container', () => {
|
||||||
|
expect(vm.$el.querySelector('.flash-container')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a container for suggestions', () => {
|
||||||
|
expect(vm.$refs.container).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders suggestions', () => {
|
||||||
|
expect(vm.renderSuggestions).toHaveBeenCalled();
|
||||||
|
expect(vm.$el.innerHTML.includes('Suggestion 1')).toBe(true);
|
||||||
|
expect(vm.$el.innerHTML.includes('Suggestion 2')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractNewLines', () => {
|
||||||
|
it('extracts suggested lines', () => {
|
||||||
|
const expectedReturn = [
|
||||||
|
{ content: 'Line 1\n', lineNumber: 1 },
|
||||||
|
{ content: 'Line 2\n', lineNumber: 2 },
|
||||||
|
{ content: 'Line 3\n', lineNumber: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(vm.extractNewLines(generateMockLines())).toEqual(expectedReturn);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments line number for each extracted line', () => {
|
||||||
|
expect(extractedLines[0].lineNumber).toEqual(1);
|
||||||
|
expect(extractedLines[1].lineNumber).toEqual(2);
|
||||||
|
expect(extractedLines[2].lineNumber).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array if no lines are found', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
|
||||||
|
expect(vm.extractNewLines(el)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateDiff', () => {
|
||||||
|
it('generates a diff table', () => {
|
||||||
|
expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a diff table that contains contents of `oldLineContent`', () => {
|
||||||
|
expect(diffTable.innerHTML.includes(vm.fromContent)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a diff table that contains contents the suggested lines', () => {
|
||||||
|
extractedLines.forEach((line, i) => {
|
||||||
|
expect(diffTable.innerHTML.includes(extractedLines[i].content)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a diff table with the correct line number for each suggested line', () => {
|
||||||
|
const lines = diffTable.getElementsByClassName('qa-new-diff-line-number');
|
||||||
|
|
||||||
|
expect([...lines][0].innerHTML).toBe('1');
|
||||||
|
expect([...lines][1].innerHTML).toBe('2');
|
||||||
|
expect([...lines][2].innerHTML).toBe('3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,35 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Banzai::Filter::SuggestionFilter do
|
||||||
|
include FilterSpecHelper
|
||||||
|
|
||||||
|
let(:input) { "<pre class='code highlight js-syntax-highlight suggestion'><code>foo\n</code></pre>" }
|
||||||
|
let(:default_context) do
|
||||||
|
{ suggestions_filter_enabled: true }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes `js-render-suggestion` class' do
|
||||||
|
doc = filter(input, default_context)
|
||||||
|
result = doc.css('code').first
|
||||||
|
|
||||||
|
expect(result[:class]).to include('js-render-suggestion')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes no `js-render-suggestion` when feature disabled' do
|
||||||
|
stub_feature_flags(diff_suggestions: false)
|
||||||
|
|
||||||
|
doc = filter(input, default_context)
|
||||||
|
result = doc.css('code').first
|
||||||
|
|
||||||
|
expect(result[:class]).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes no `js-render-suggestion` when filter is disabled' do
|
||||||
|
doc = filter(input)
|
||||||
|
result = doc.css('code').first
|
||||||
|
|
||||||
|
expect(result[:class]).to be_nil
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,32 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Banzai::SuggestionsParser do
|
||||||
|
describe '.parse' do
|
||||||
|
it 'returns a list of suggestion contents' do
|
||||||
|
markdown = <<-MARKDOWN.strip_heredoc
|
||||||
|
```suggestion
|
||||||
|
foo
|
||||||
|
bar
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
```suggestion
|
||||||
|
xpto
|
||||||
|
baz
|
||||||
|
```
|
||||||
|
|
||||||
|
```thing
|
||||||
|
this is not a suggestion, it's a thing
|
||||||
|
```
|
||||||
|
MARKDOWN
|
||||||
|
|
||||||
|
expect(described_class.parse(markdown)).to eq([" foo\n bar",
|
||||||
|
" xpto\n baz"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -37,6 +37,7 @@ notes:
|
||||||
- events
|
- events
|
||||||
- system_note_metadata
|
- system_note_metadata
|
||||||
- note_diff_file
|
- note_diff_file
|
||||||
|
- suggestions
|
||||||
label_links:
|
label_links:
|
||||||
- target
|
- target
|
||||||
- label
|
- label
|
||||||
|
|
|
@ -117,6 +117,7 @@ describe Gitlab::UsageData do
|
||||||
releases
|
releases
|
||||||
remote_mirrors
|
remote_mirrors
|
||||||
snippets
|
snippets
|
||||||
|
suggestions
|
||||||
todos
|
todos
|
||||||
uploads
|
uploads
|
||||||
web_hooks
|
web_hooks
|
||||||
|
|
|
@ -318,6 +318,24 @@ describe DiffNote do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#supports_suggestion?' do
|
||||||
|
context 'when noteable does not support suggestions' do
|
||||||
|
it 'returns false' do
|
||||||
|
allow(subject.noteable).to receive(:supports_suggestion?) { false }
|
||||||
|
|
||||||
|
expect(subject.supports_suggestion?).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when line is not suggestible' do
|
||||||
|
it 'returns false' do
|
||||||
|
allow_any_instance_of(Gitlab::Diff::Line).to receive(:suggestible?) { false }
|
||||||
|
|
||||||
|
expect(subject.supports_suggestion?).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "image diff notes" do
|
describe "image diff notes" do
|
||||||
let(:path) { "files/images/any_image.png" }
|
let(:path) { "files/images/any_image.png" }
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Suggestion do
|
||||||
|
let(:suggestion) { create(:suggestion) }
|
||||||
|
|
||||||
|
describe 'associations' do
|
||||||
|
it { is_expected.to belong_to(:note) }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'validations' do
|
||||||
|
it { is_expected.to validate_presence_of(:note) }
|
||||||
|
|
||||||
|
context 'when suggestion is applied' do
|
||||||
|
before do
|
||||||
|
allow(subject).to receive(:applied?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it { is_expected.to validate_presence_of(:commit_id) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#appliable?' do
|
||||||
|
context 'when note does not support suggestions' do
|
||||||
|
it 'returns false' do
|
||||||
|
expect_next_instance_of(DiffNote) do |note|
|
||||||
|
allow(note).to receive(:supports_suggestion?) { false }
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(suggestion).not_to be_appliable
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when patch is already applied' do
|
||||||
|
let(:suggestion) { create(:suggestion, :applied) }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(suggestion).not_to be_appliable
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when merge request is not opened' do
|
||||||
|
let(:merge_request) { create(:merge_request, :merged) }
|
||||||
|
let(:note) do
|
||||||
|
create(:diff_note_on_merge_request, project: merge_request.project,
|
||||||
|
noteable: merge_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:suggestion) { create(:suggestion, note: note) }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(suggestion).not_to be_appliable
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,83 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe API::Suggestions do
|
||||||
|
let(:project) { create(:project, :repository) }
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
let(:merge_request) do
|
||||||
|
create(:merge_request, source_project: project,
|
||||||
|
target_project: project)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:position) do
|
||||||
|
Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
|
||||||
|
new_path: "files/ruby/popen.rb",
|
||||||
|
old_line: nil,
|
||||||
|
new_line: 9,
|
||||||
|
diff_refs: merge_request.diff_refs)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:diff_note) do
|
||||||
|
create(:diff_note_on_merge_request, noteable: merge_request,
|
||||||
|
position: position,
|
||||||
|
project: project)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PUT /suggestions/:id/apply" do
|
||||||
|
let(:url) { "/suggestions/#{suggestion.id}/apply" }
|
||||||
|
|
||||||
|
context 'when successfully applies patch' do
|
||||||
|
let(:suggestion) do
|
||||||
|
create(:suggestion, note: diff_note,
|
||||||
|
from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
|
||||||
|
to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 200 with json content' do
|
||||||
|
project.add_maintainer(user)
|
||||||
|
|
||||||
|
put api(url, user), id: suggestion.id
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(200)
|
||||||
|
expect(json_response)
|
||||||
|
.to include('id', 'from_original_line', 'to_original_line',
|
||||||
|
'from_line', 'to_line', 'appliable', 'applied',
|
||||||
|
'from_content', 'to_content')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when not able to apply patch' do
|
||||||
|
let(:suggestion) do
|
||||||
|
create(:suggestion, :unappliable, note: diff_note)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 400 with json content' do
|
||||||
|
project.add_maintainer(user)
|
||||||
|
|
||||||
|
put api(url, user), id: suggestion.id
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(400)
|
||||||
|
expect(json_response).to eq({ 'message' => 'Suggestion is not appliable' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when unauthorized' do
|
||||||
|
let(:suggestion) do
|
||||||
|
create(:suggestion, note: diff_note,
|
||||||
|
from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
|
||||||
|
to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 403 with json content' do
|
||||||
|
project.add_reporter(user)
|
||||||
|
|
||||||
|
put api(url, user), id: suggestion.id
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(403)
|
||||||
|
expect(json_response).to eq({ 'message' => '403 Forbidden' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe SuggestionEntity do
|
||||||
|
include RepoHelpers
|
||||||
|
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:request) { double('request', current_user: user) }
|
||||||
|
let(:suggestion) { create(:suggestion) }
|
||||||
|
let(:entity) { described_class.new(suggestion, request: request) }
|
||||||
|
|
||||||
|
subject { entity.as_json }
|
||||||
|
|
||||||
|
it 'exposes correct attributes' do
|
||||||
|
expect(subject).to include(:id, :from_original_line, :to_original_line, :from_line,
|
||||||
|
:to_line, :appliable, :applied, :from_content, :to_content)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'exposes current user abilities' do
|
||||||
|
expect(subject[:current_user]).to include(:can_apply)
|
||||||
|
end
|
||||||
|
end
|
|
@ -20,6 +20,29 @@ describe Notes::UpdateService do
|
||||||
@note.reload
|
@note.reload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'suggestions' do
|
||||||
|
it 'refreshes note suggestions' do
|
||||||
|
markdown = <<-MARKDOWN.strip_heredoc
|
||||||
|
```suggestion
|
||||||
|
foo
|
||||||
|
```
|
||||||
|
|
||||||
|
```suggestion
|
||||||
|
bar
|
||||||
|
```
|
||||||
|
MARKDOWN
|
||||||
|
|
||||||
|
suggestion = create(:suggestion)
|
||||||
|
note = suggestion.note
|
||||||
|
|
||||||
|
expect { described_class.new(project, user, note: markdown).execute(note) }
|
||||||
|
.to change { note.suggestions.count }.from(1).to(2)
|
||||||
|
|
||||||
|
expect(note.suggestions.order(:relative_order).map(&:to_content))
|
||||||
|
.to eq([" foo\n", " bar\n"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'todos' do
|
context 'todos' do
|
||||||
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) }
|
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) }
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,31 @@ describe PreviewMarkdownService do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'suggestions' do
|
||||||
|
let(:params) { { text: "```suggestion\nfoo\n```", preview_suggestions: preview_suggestions } }
|
||||||
|
let(:service) { described_class.new(project, user, params) }
|
||||||
|
|
||||||
|
context 'when preview markdown param is present' do
|
||||||
|
let(:preview_suggestions) { true }
|
||||||
|
|
||||||
|
it 'returns users referenced in text' do
|
||||||
|
result = service.execute
|
||||||
|
|
||||||
|
expect(result[:suggestions]).to eq(['foo'])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when preview markdown param is not present' do
|
||||||
|
let(:preview_suggestions) { false }
|
||||||
|
|
||||||
|
it 'returns users referenced in text' do
|
||||||
|
result = service.execute
|
||||||
|
|
||||||
|
expect(result[:suggestions]).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'new note with quick actions' do
|
context 'new note with quick actions' do
|
||||||
let(:issue) { create(:issue, project: project) }
|
let(:issue) { create(:issue, project: project) }
|
||||||
let(:params) do
|
let(:params) do
|
||||||
|
|
|
@ -0,0 +1,229 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Suggestions::ApplyService do
|
||||||
|
include ProjectForksHelper
|
||||||
|
|
||||||
|
let(:project) { create(:project, :repository) }
|
||||||
|
let(:user) { create(:user, :commit_email) }
|
||||||
|
|
||||||
|
let(:position) do
|
||||||
|
Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
|
||||||
|
new_path: "files/ruby/popen.rb",
|
||||||
|
old_line: nil,
|
||||||
|
new_line: 9,
|
||||||
|
diff_refs: merge_request.diff_refs)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:suggestion) do
|
||||||
|
create(:suggestion, note: diff_note,
|
||||||
|
from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
|
||||||
|
to_content: " raise RuntimeError, 'Explosion'\n # explosion?\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
subject { described_class.new(user) }
|
||||||
|
|
||||||
|
context 'patch is appliable' do
|
||||||
|
let(:expected_content) do
|
||||||
|
<<-CONTENT.strip_heredoc
|
||||||
|
require 'fileutils'
|
||||||
|
require 'open3'
|
||||||
|
|
||||||
|
module Popen
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def popen(cmd, path=nil)
|
||||||
|
unless cmd.is_a?(Array)
|
||||||
|
raise RuntimeError, 'Explosion'
|
||||||
|
# explosion?
|
||||||
|
end
|
||||||
|
|
||||||
|
path ||= Dir.pwd
|
||||||
|
|
||||||
|
vars = {
|
||||||
|
"PWD" => path
|
||||||
|
}
|
||||||
|
|
||||||
|
options = {
|
||||||
|
chdir: path
|
||||||
|
}
|
||||||
|
|
||||||
|
unless File.directory?(path)
|
||||||
|
FileUtils.mkdir_p(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
@cmd_output = ""
|
||||||
|
@cmd_status = 0
|
||||||
|
|
||||||
|
Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
|
||||||
|
@cmd_output << stdout.read
|
||||||
|
@cmd_output << stderr.read
|
||||||
|
@cmd_status = wait_thr.value.exitstatus
|
||||||
|
end
|
||||||
|
|
||||||
|
return @cmd_output, @cmd_status
|
||||||
|
end
|
||||||
|
end
|
||||||
|
CONTENT
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'non-fork project' do
|
||||||
|
let(:merge_request) do
|
||||||
|
create(:merge_request, source_project: project,
|
||||||
|
target_project: project)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:diff_note) do
|
||||||
|
create(:diff_note_on_merge_request, noteable: merge_request,
|
||||||
|
position: position,
|
||||||
|
project: project)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
project.add_maintainer(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the file with the new contents' do
|
||||||
|
subject.execute(suggestion)
|
||||||
|
|
||||||
|
blob = project.repository.blob_at_branch(merge_request.source_branch,
|
||||||
|
position.new_path)
|
||||||
|
|
||||||
|
expect(blob.data).to eq(expected_content)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns success status' do
|
||||||
|
result = subject.execute(suggestion)
|
||||||
|
|
||||||
|
expect(result[:status]).to eq(:success)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates suggestion applied and commit_id columns' do
|
||||||
|
expect { subject.execute(suggestion) }
|
||||||
|
.to change(suggestion, :applied)
|
||||||
|
.from(false).to(true)
|
||||||
|
.and change(suggestion, :commit_id)
|
||||||
|
.from(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'created commit has users email and name' do
|
||||||
|
subject.execute(suggestion)
|
||||||
|
|
||||||
|
commit = project.repository.commit
|
||||||
|
|
||||||
|
expect(user.commit_email).not_to eq(user.email)
|
||||||
|
expect(commit.author_email).to eq(user.commit_email)
|
||||||
|
expect(commit.committer_email).to eq(user.commit_email)
|
||||||
|
expect(commit.author_name).to eq(user.name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'fork-project' do
|
||||||
|
let(:project) { create(:project, :public, :repository) }
|
||||||
|
|
||||||
|
let(:forked_project) do
|
||||||
|
fork_project_with_submodules(project, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:merge_request) do
|
||||||
|
create(:merge_request,
|
||||||
|
source_branch: 'conflict-resolvable-fork', source_project: forked_project,
|
||||||
|
target_branch: 'conflict-start', target_project: project)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:diff_note) do
|
||||||
|
create(:diff_note_on_merge_request, noteable: merge_request, position: position, project: project)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
project.add_maintainer(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates file in the source project' do
|
||||||
|
expect(Files::UpdateService).to receive(:new)
|
||||||
|
.with(merge_request.source_project, user, anything)
|
||||||
|
.and_call_original
|
||||||
|
|
||||||
|
subject.execute(suggestion)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'no permission' do
|
||||||
|
let(:merge_request) do
|
||||||
|
create(:merge_request, source_project: project,
|
||||||
|
target_project: project)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:diff_note) do
|
||||||
|
create(:diff_note_on_merge_request, noteable: merge_request,
|
||||||
|
position: position,
|
||||||
|
project: project)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'user cannot write in project repo' do
|
||||||
|
before do
|
||||||
|
project.add_reporter(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error' do
|
||||||
|
result = subject.execute(suggestion)
|
||||||
|
|
||||||
|
expect(result).to eq(message: "You are not allowed to push into this branch",
|
||||||
|
status: :error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'patch is not appliable' do
|
||||||
|
let(:merge_request) do
|
||||||
|
create(:merge_request, source_project: project,
|
||||||
|
target_project: project)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:diff_note) do
|
||||||
|
create(:diff_note_on_merge_request, noteable: merge_request,
|
||||||
|
position: position,
|
||||||
|
project: project)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
project.add_maintainer(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'suggestion was already applied' do
|
||||||
|
it 'returns success status' do
|
||||||
|
result = subject.execute(suggestion)
|
||||||
|
|
||||||
|
expect(result[:status]).to eq(:success)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'note is outdated' do
|
||||||
|
before do
|
||||||
|
allow(diff_note).to receive(:active?) { false }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error message' do
|
||||||
|
result = subject.execute(suggestion)
|
||||||
|
|
||||||
|
expect(result).to eq(message: 'Suggestion is not appliable',
|
||||||
|
status: :error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'suggestion was already applied' do
|
||||||
|
before do
|
||||||
|
suggestion.update!(applied: true, commit_id: 'sha')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error message' do
|
||||||
|
result = subject.execute(suggestion)
|
||||||
|
|
||||||
|
expect(result).to eq(message: 'Suggestion is not appliable',
|
||||||
|
status: :error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,110 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Suggestions::CreateService do
|
||||||
|
let(:project_with_repo) { create(:project, :repository) }
|
||||||
|
let(:merge_request) do
|
||||||
|
create(:merge_request, source_project: project_with_repo,
|
||||||
|
target_project: project_with_repo)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:position) do
|
||||||
|
Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
|
||||||
|
new_path: "files/ruby/popen.rb",
|
||||||
|
old_line: nil,
|
||||||
|
new_line: 14,
|
||||||
|
diff_refs: merge_request.diff_refs)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:markdown) do
|
||||||
|
<<-MARKDOWN.strip_heredoc
|
||||||
|
```suggestion
|
||||||
|
foo
|
||||||
|
bar
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
```suggestion
|
||||||
|
xpto
|
||||||
|
baz
|
||||||
|
```
|
||||||
|
|
||||||
|
```thing
|
||||||
|
this is not a suggestion, it's a thing
|
||||||
|
```
|
||||||
|
MARKDOWN
|
||||||
|
end
|
||||||
|
|
||||||
|
subject { described_class.new(note) }
|
||||||
|
|
||||||
|
describe '#execute' do
|
||||||
|
context 'should not try to parse suggestions' do
|
||||||
|
context 'when not a diff note for merge requests' do
|
||||||
|
let(:note) do
|
||||||
|
create(:diff_note_on_commit, project: project_with_repo,
|
||||||
|
note: markdown)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not try to parse suggestions' do
|
||||||
|
expect(Banzai::SuggestionsParser).not_to receive(:parse)
|
||||||
|
|
||||||
|
subject.execute
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when diff note is not for text' do
|
||||||
|
let(:note) do
|
||||||
|
create(:diff_note_on_merge_request, project: project_with_repo,
|
||||||
|
noteable: merge_request,
|
||||||
|
position: position,
|
||||||
|
note: markdown)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not try to parse suggestions' do
|
||||||
|
allow(note).to receive(:on_text?) { false }
|
||||||
|
|
||||||
|
expect(Banzai::SuggestionsParser).not_to receive(:parse)
|
||||||
|
|
||||||
|
subject.execute
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'should create suggestions' do
|
||||||
|
let(:note) do
|
||||||
|
create(:diff_note_on_merge_request, project: project_with_repo,
|
||||||
|
noteable: merge_request,
|
||||||
|
position: position,
|
||||||
|
note: markdown)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'single line suggestions' do
|
||||||
|
it 'persists suggestion records' do
|
||||||
|
expect { subject.execute }
|
||||||
|
.to change { note.suggestions.count }
|
||||||
|
.from(0)
|
||||||
|
.to(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'persists original from_content lines and suggested lines' do
|
||||||
|
subject.execute
|
||||||
|
|
||||||
|
suggestions = note.suggestions.order(:relative_order)
|
||||||
|
|
||||||
|
suggestion_1 = suggestions.first
|
||||||
|
suggestion_2 = suggestions.last
|
||||||
|
|
||||||
|
expect(suggestion_1).to have_attributes(from_content: " vars = {\n",
|
||||||
|
to_content: " foo\n bar\n")
|
||||||
|
|
||||||
|
expect(suggestion_2).to have_attributes(from_content: " vars = {\n",
|
||||||
|
to_content: " xpto\n baz\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue