summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared/components/markdown
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/markdown')
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue100
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue60
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue136
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue12
6 files changed, 397 insertions, 8 deletions
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 21d6519191f..2f7ed4a982c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,17 +1,21 @@
<script>
import $ from 'jquery';
-import { s__ } from '~/locale';
+import _ from 'underscore';
+import { __ } from '~/locale';
+import { stripHtml } from '~/lib/utils/text_utility';
import Flash from '../../../flash';
import GLForm from '../../../gl_form';
import markdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue';
import icon from '../icon.vue';
+import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
export default {
components: {
markdownHeader,
markdownToolbar,
icon,
+ Suggestions,
},
props: {
markdownPreviewPath: {
@@ -48,12 +52,33 @@ export default {
required: false,
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() {
return {
markdownPreview: '',
referencedCommands: '',
referencedUsers: '',
+ hasSuggestion: false,
markdownPreviewLoading: false,
previewMarkdown: false,
};
@@ -63,6 +88,39 @@ export default {
const referencedUsersThreshold = 10;
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() {
/*
@@ -99,11 +157,12 @@ export default {
if (text) {
this.markdownPreviewLoading = true;
+ this.markdownPreview = __('Loading…');
this.$http
.post(this.versionedPreviewPath(), { text })
.then(resp => resp.json())
.then(data => this.renderMarkdown(data))
- .catch(() => new Flash(s__('Error loading markdown preview')));
+ .catch(() => new Flash(__('Error loading markdown preview')));
} else {
this.renderMarkdown();
}
@@ -121,6 +180,7 @@ export default {
if (data.references) {
this.referencedCommands = data.references.commands;
this.referencedUsers = data.references.users;
+ this.hasSuggestion = data.references.suggestions && data.references.suggestions.length;
}
this.$nextTick(() => {
@@ -146,6 +206,8 @@ export default {
>
<markdown-header
:preview-markdown="previewMarkdown"
+ :line-content="lineContent"
+ :can-suggest="canSuggest"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
/>
@@ -162,17 +224,39 @@ export default {
/>
</div>
</div>
- <div v-show="previewMarkdown" class="md md-preview-holder md-preview js-vue-md-preview">
- <div ref="markdown-preview" v-html="markdownPreview"></div>
- <span v-if="markdownPreviewLoading"> Loading... </span>
- </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
+ v-show="previewMarkdown"
+ ref="markdown-preview"
+ class="md-preview js-vue-md-preview md md-preview-holder"
+ v-html="markdownPreview"
+ ></div>
+ </template>
<template v-if="previewMarkdown && !markdownPreviewLoading">
<div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
<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>
- <span class="js-referenced-users-count"> {{ referencedUsers.length }} </span>
+ <span class="js-referenced-users-count">{{ referencedUsers.length }}</span>
</strong>
people to the discussion. Proceed with caution.
</span>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 4c4ba537065..bf4d42670ee 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -17,6 +17,16 @@ export default {
type: Boolean,
required: true,
},
+ lineContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canSuggest: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
mdTable() {
@@ -27,6 +37,9 @@ export default {
'| cell | cell |',
].join('\n');
},
+ mdSuggestion() {
+ return ['```suggestion', `{text}`, '```'].join('\n');
+ },
},
mounted() {
$(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
@@ -119,6 +132,16 @@ export default {
:button-title="__('Add a 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
v-gl-tooltip
aria-label="Go full screen"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
new file mode 100644
index 00000000000..f98560f7336
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -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>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
new file mode 100644
index 00000000000..563e2f94fcc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -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>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
new file mode 100644
index 00000000000..7c6dbee3e19
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -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>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index a6d2cecdf7e..4572caa907b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -37,6 +37,16 @@ export default {
required: false,
default: false,
},
+ tagContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ cursorOffset: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
};
</script>
@@ -45,8 +55,10 @@ export default {
<button
v-gl-tooltip
:data-md-tag="tag"
+ :data-md-cursor-offset="cursorOffset"
:data-md-select="tagSelect"
:data-md-block="tagBlock"
+ :data-md-tag-content="tagContent"
:data-md-prepend="prepend"
:title="buttonTitle"
:aria-label="buttonTitle"