diff options
5 files changed, 216 insertions, 191 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/event_hub.js b/app/assets/javascripts/behaviors/markdown/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 670f66b005e..c582b835567 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -1,8 +1,8 @@ -import $ from 'jquery'; import Mousetrap from 'mousetrap'; import Sidebar from '../../right_sidebar'; import Shortcuts from './shortcuts'; import { CopyAsGFM } from '../markdown/copy_as_gfm'; +import eventHub from '../markdown/event_hub'; import { getSelectedFragment } from '~/lib/utils/common_utils'; export default class ShortcutsIssuable extends Shortcuts { @@ -23,16 +23,10 @@ export default class ShortcutsIssuable extends Shortcuts { } static replyWithSelectedText() { - const $replyField = $('.js-main-target-form .js-vue-comment-form'); - - if (!$replyField.length || $replyField.is(':hidden') /* Other tab selected in MR */) { - return false; - } - const documentFragment = getSelectedFragment(document.querySelector('#content-body')); if (!documentFragment) { - $replyField.focus(); + eventHub.$emit('focus'); return false; } @@ -56,38 +50,13 @@ export default class ShortcutsIssuable extends Shortcuts { // If there is no message, just select the reply field if (!foundMessage) { - $replyField.focus(); + eventHub.$emit('focus'); return false; } } const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); - const blockquoteEl = document.createElement('blockquote'); - blockquoteEl.appendChild(el); - CopyAsGFM.nodeToGFM(blockquoteEl) - .then(text => { - if (text.trim() === '') { - return false; - } - - // If replyField already has some content, add a newline before our quote - const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; - $replyField - .val((a, current) => `${current}${separator}${text}\n\n`) - .trigger('input') - .trigger('change'); - - // Trigger autosize - const event = document.createEvent('Event'); - event.initEvent('autosize:update', true, false); - $replyField.get(0).dispatchEvent(event); - - // Focus the input field - $replyField.focus(); - - return false; - }) - .catch(() => {}); + eventHub.$emit('quoteNode', el); return false; } diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 06b0b850941..b41ec21b1ef 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -332,6 +332,7 @@ Please check your network connection and try again.`; :textarea-supports-quick-actions="true" :textarea-label="__('Comment')" :editable="!isSubmitting" + :subscribe-to-global-events="true" @edit-previous="editCurrentUserLastNote()" @save="handleSave()" @cancel="cancelHandler(true)" diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index a05947a1813..5f352641070 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -6,6 +6,8 @@ import Autosize from 'autosize'; import { __ } from '~/locale'; import { stripHtml } from '~/lib/utils/text_utility'; import Flash from '../../../flash'; +import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; +import eventHub from '~/behaviors/markdown/event_hub'; import markdownHeader from './header.vue'; import markdownToolbar from './toolbar.vue'; import icon from '../icon.vue'; @@ -111,6 +113,11 @@ export default { required: false, default: '', }, + subscribeToGlobalEvents: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -228,6 +235,11 @@ export default { Autosize(this.$refs.textarea); this.autosizeTextarea(); + + if (this.subscribeToGlobalEvents) { + eventHub.$on('focus', this.focusIfVisible); + eventHub.$on('quoteNode', this.quoteNodeIfVisible); + } }, beforeDestroy() { if (this.autocomplete) { @@ -239,6 +251,11 @@ export default { } Autosize.destroy(this.$refs.textarea); + + if (this.subscribeToGlobalEvents) { + eventHub.$off('focus', this.focusIfVisible); + eventHub.$off('quoteNode', this.quoteNodeIfVisible); + } }, methods: { setupAutocomplete() { @@ -293,6 +310,31 @@ export default { } }, + quoteNode(node) { + const blockquoteEl = document.createElement('blockquote'); + blockquoteEl.appendChild(node.cloneNode(true)); + + const current = this.currentValue.trim(); + const separator = current.length ? '\n\n' : ''; + + CopyAsGFM.nodeToGFM(blockquoteEl) + .then(markdown => this.setCurrentValue(`${current}${separator}${markdown}\n\n`)) + .then(this.switchToEditor) + .then(this.$nextTick) + .then(this.focus) + .catch(() => {}); + }, + + ifVisible(func) { + if ($(this.$el).is(':visible')) { + func(); + } + }, + + quoteNodeIfVisible(node) { + this.ifVisible(() => this.quoteNode(node)); + }, + blur() { if (this.modeIsMarkdown) { this.$refs.textarea.blur(); @@ -305,6 +347,10 @@ export default { } }, + focusIfVisible() { + this.ifVisible(this.focus); + }, + renderMarkdown() { if (!this.renderedOutdated || this.renderedLoading) return; diff --git a/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js index 5e457a4e823..5a14c1e5156 100644 --- a/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -2,40 +2,38 @@ no-underscore-dangle */ -import $ from 'jquery'; +import Vue from 'vue'; import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; - -const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form'; +import fieldComponent from '~/vue_shared/components/markdown/field.vue'; describe('ShortcutsIssuable', function() { - const fixtureName = 'snippets/show.html'; - preloadFixtures(fixtureName); - beforeAll(done => { initCopyAsGFM(); - // Fake call to nodeToGfm so the import of lazy bundle happened + // Fake call to nodeToGFM so the import of lazy bundle happened CopyAsGFM.nodeToGFM(document.createElement('div')) - .then(() => { - done(); - }) + .then(done) .catch(done.fail); }); - beforeEach(() => { - loadFixtures(fixtureName); - $('body').append( - `<div class="js-main-target-form"> - <textare class="js-vue-comment-form"></textare> - </div>`, - ); - document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); - this.shortcut = new ShortcutsIssuable(true); + let vm; + + beforeEach(done => { + const Component = Vue.extend(fieldComponent); + + vm = new Component({ + propsData: { + markdownPreviewPath: '/preview', + markdownDocsPath: '/docs', + }, + }).$mount(); + + Vue.nextTick(done); }); afterEach(() => { - $(FORM_SELECTOR).remove(); + vm.$destroy(); }); describe('replyWithSelectedText', () => { @@ -53,17 +51,29 @@ describe('ShortcutsIssuable', function() { }); }; describe('with empty selection', () => { - it('does not return an error', () => { - ShortcutsIssuable.replyWithSelectedText(true); - - expect($(FORM_SELECTOR).val()).toBe(''); + it('does not return an error', done => { + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(() => { + expect(vm.currentValue).toBe(''); + }) + .then(done) + .catch(done.fail); }); - it('triggers `focus`', () => { - const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); - ShortcutsIssuable.replyWithSelectedText(true); - - expect(spy).toHaveBeenCalled(); + it('triggers `focus`', done => { + const spy = spyOn(vm, 'focus'); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(() => { + expect(spy).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); }); @@ -73,54 +83,47 @@ describe('ShortcutsIssuable', function() { }); it('leaves existing input intact', done => { - $(FORM_SELECTOR).val('This text was already here.'); - - expect($(FORM_SELECTOR).val()).toBe('This text was already here.'); - - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect($(FORM_SELECTOR).val()).toBe( - 'This text was already here.\n\n> Selected text.\n\n', - ); - done(); - }); - }); - - it('triggers `input`', done => { - let triggered = false; - $(FORM_SELECTOR).on('input', () => { - triggered = true; - }); - - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect(triggered).toBe(true); - done(); - }); + vm.value = 'This text was already here.'; + + Vue.nextTick() + .then(() => ShortcutsIssuable.replyWithSelectedText(vm)) + .then(Vue.nextTick) + .then(Vue.nextTick) + .then(() => { + expect(vm.currentValue).toBe('This text was already here.\n\n> Selected text.\n\n'); + }) + .then(done) + .catch(done.fail); }); it('triggers `focus`', done => { - const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect(spy).toHaveBeenCalled(); - done(); - }); + const spy = spyOn(vm, 'focus'); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(Vue.nextTick) + .then(Vue.nextTick) + .then(() => { + expect(spy).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); }); describe('with a one-line selection', () => { it('quotes the selection', done => { stubSelection('<p>This text has been selected.</p>'); - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect($(FORM_SELECTOR).val()).toBe('> This text has been selected.\n\n'); - done(); - }); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(() => { + expect(vm.currentValue).toBe('> This text has been selected.\n\n'); + }) + .then(done) + .catch(done.fail); }); }); @@ -129,14 +132,17 @@ describe('ShortcutsIssuable', function() { stubSelection( '<p>Selected line one.</p>\n<p>Selected line two.</p>\n<p>Selected line three.</p>', ); - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect($(FORM_SELECTOR).val()).toBe( - '> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n', - ); - done(); - }); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(() => { + expect(vm.currentValue).toBe( + '> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n', + ); + }) + .then(done) + .catch(done.fail); }); }); @@ -146,22 +152,28 @@ describe('ShortcutsIssuable', function() { }); it('does not add anything to the input', done => { - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect($(FORM_SELECTOR).val()).toBe(''); - done(); - }); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(() => { + expect(vm.currentValue).toBe(''); + }) + .then(done) + .catch(done.fail); }); it('triggers `focus`', done => { - const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect(spy).toHaveBeenCalled(); - done(); - }); + const spy = spyOn(vm, 'focus'); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(() => { + expect(spy).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); }); @@ -171,36 +183,30 @@ describe('ShortcutsIssuable', function() { }); it('only adds the valid part to the input', done => { - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect($(FORM_SELECTOR).val()).toBe('> Selected text.\n\n'); - done(); - }); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(() => { + expect(vm.currentValue).toBe('> Selected text.\n\n'); + }) + .then(done) + .catch(done.fail); }); it('triggers `focus`', done => { - const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect(spy).toHaveBeenCalled(); - done(); - }); - }); - - it('triggers `input`', done => { - let triggered = false; - $(FORM_SELECTOR).on('input', () => { - triggered = true; - }); - - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect(triggered).toBe(true); - done(); - }); + const spy = spyOn(vm, 'focus'); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(Vue.nextTick) + .then(Vue.nextTick) + .then(() => { + expect(spy).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); }); @@ -225,36 +231,30 @@ describe('ShortcutsIssuable', function() { }); it('adds the quoted selection to the input', done => { - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n'); - done(); - }); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(() => { + expect(vm.currentValue).toBe('> *Selected text.*\n\n'); + }) + .then(done) + .catch(done.fail); }); it('triggers `focus`', done => { - const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect(spy).toHaveBeenCalled(); - done(); - }); - }); - - it('triggers `input`', done => { - let triggered = false; - $(FORM_SELECTOR).on('input', () => { - triggered = true; - }); - - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect(triggered).toBe(true); - done(); - }); + const spy = spyOn(vm, 'focus'); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(Vue.nextTick) + .then(Vue.nextTick) + .then(() => { + expect(spy).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); }); @@ -279,22 +279,28 @@ describe('ShortcutsIssuable', function() { }); it('does not add anything to the input', done => { - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect($(FORM_SELECTOR).val()).toBe(''); - done(); - }); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(() => { + expect(vm.currentValue).toBe(''); + }) + .then(done) + .catch(done.fail); }); it('triggers `focus`', done => { - const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); - ShortcutsIssuable.replyWithSelectedText(true); - - setTimeout(() => { - expect(spy).toHaveBeenCalled(); - done(); - }); + const spy = spyOn(vm, 'focus'); + ShortcutsIssuable.replyWithSelectedText(vm); + + Vue.nextTick() + .then(Vue.nextTick) + .then(() => { + expect(spy).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); }); }); |