diff options
7 files changed, 234 insertions, 32 deletions
diff --git a/app/assets/javascripts/lib/utils/autosave.js b/app/assets/javascripts/lib/utils/autosave.js new file mode 100644 index 00000000000..023c336db02 --- /dev/null +++ b/app/assets/javascripts/lib/utils/autosave.js @@ -0,0 +1,32 @@ +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + +export const clearDraft = autosaveKey => { + try { + window.localStorage.removeItem(`autosave/${autosaveKey}`); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } +}; + +export const getDraft = autosaveKey => { + try { + return window.localStorage.getItem(`autosave/${autosaveKey}`); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return null; + } +}; + +export const updateDraft = (autosaveKey, text) => { + try { + window.localStorage.setItem(`autosave/${autosaveKey}`, text); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } +}; + +export const getDiscussionReplyKey = (noteableType, discussionId) => + ['Note', capitalizeFirstCharacter(noteableType), discussionId, 'Reply'].join('/'); diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 92258a25438..57d6b181bd7 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -7,6 +7,7 @@ import markdownField from '../../vue_shared/components/markdown/field.vue'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; import { __ } from '~/locale'; +import { getDraft, updateDraft } from '~/lib/utils/autosave'; export default { name: 'NoteForm', @@ -65,10 +66,21 @@ export default { required: false, default: '', }, + autosaveKey: { + type: String, + required: false, + default: '', + }, }, data() { + let updatedNoteBody = this.noteBody; + + if (!updatedNoteBody && this.autosaveKey) { + updatedNoteBody = getDraft(this.autosaveKey) || ''; + } + return { - updatedNoteBody: this.noteBody, + updatedNoteBody, conflictWhileEditing: false, isSubmitting: false, isResolving: this.resolveDiscussion, @@ -175,6 +187,12 @@ export default { // Sends information about confirm message and if the textarea has changed this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody); }, + onInput() { + if (this.autosaveKey) { + const { autosaveKey, updatedNoteBody: text } = this; + updateDraft(autosaveKey, text); + } + }, }, }; </script> @@ -218,6 +236,7 @@ export default { @keydown.ctrl.enter="handleKeySubmit()" @keydown.up="editMyLastNote()" @keydown.esc="cancelHandler(true)" + @input="onInput" ></textarea> </markdown-field> <div class="note-form-actions clearfix"> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index fc51998935d..0fabbfb06b5 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -4,6 +4,7 @@ import { mapActions, mapGetters } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; +import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import systemNote from '~/vue_shared/components/notes/system_note.vue'; import icon from '~/vue_shared/components/icon.vue'; import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; @@ -21,7 +22,6 @@ import noteForm from './note_form.vue'; import diffWithNote from './diff_with_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; -import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; @@ -54,7 +54,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [autosave, noteable, resolvable, discussionNavigation, diffLineNoteFormMixin], + mixins: [noteable, resolvable, discussionNavigation, diffLineNoteFormMixin], props: { discussion: { type: Object, @@ -106,7 +106,10 @@ export default { 'showJumpToNextDiscussion', ]), author() { - return this.initialDiscussion.author; + return this.firstNote.author; + }, + autosaveKey() { + return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); }, canReply() { return this.getNoteableData.current_user.can_create_note; @@ -117,7 +120,7 @@ export default { hasReplies() { return this.discussion.notes.length > 1; }, - initialDiscussion() { + firstNote() { return this.discussion.notes.slice(0, 1)[0]; }, replies() { @@ -242,18 +245,6 @@ export default { return !this.discussionResolved && this.discussion.resolve_with_issue_path; }, }, - watch: { - isReplying() { - if (this.isReplying) { - this.$nextTick(() => { - // Pass an extra key to separate reply and note edit forms - this.initAutoSave({ ...this.initialDiscussion, ...this.discussion }, ['Reply']); - }); - } else { - this.disposeAutoSave(); - } - }, - }, created() { eventHub.$on('startReplying', this.onStartReplying); }, @@ -312,7 +303,7 @@ export default { } this.isReplying = false; - this.resetAutoSave(); + clearDraft(this.autosaveKey); }, saveReply(noteText, form, callback) { const postData = { @@ -338,7 +329,7 @@ export default { this.isReplying = false; this.saveNote(replyData) .then(() => { - this.resetAutoSave(); + clearDraft(this.autosaveKey); callback(); }) .catch(err => { @@ -390,8 +381,8 @@ Please check your network connection and try again.`; <div class="timeline-content"> <note-header :author="author" - :created-at="initialDiscussion.created_at" - :note-id="initialDiscussion.id" + :created-at="firstNote.created_at" + :note-id="firstNote.id" :include-toggle="true" :expanded="discussion.expanded" @toggleHandler="toggleDiscussionHandler" @@ -424,8 +415,8 @@ Please check your network connection and try again.`; <ul class="notes"> <template v-if="shouldGroupReplies"> <component - :is="componentName(initialDiscussion)" - :note="componentData(initialDiscussion)" + :is="componentName(firstNote)" + :note="componentData(firstNote)" :line="line" :commit="commit" :help-page-path="helpPagePath" @@ -512,6 +503,7 @@ Please check your network connection and try again.`; :is-editing="false" :line="diffLine" save-button-title="Comment" + :autosave-key="autosaveKey" @handleFormUpdateAddToReview="addReplyToReview" @handleFormUpdate="saveReply" @cancelForm="cancelReplyForm" diff --git a/changelogs/unreleased/winh-toggle-comment-draft.yml b/changelogs/unreleased/winh-toggle-comment-draft.yml new file mode 100644 index 00000000000..6b4aad55a05 --- /dev/null +++ b/changelogs/unreleased/winh-toggle-comment-draft.yml @@ -0,0 +1,5 @@ +--- +title: Display draft when toggling replies +merge_request: 25563 +author: +type: fixed diff --git a/spec/frontend/lib/utils/autosave_spec.js b/spec/frontend/lib/utils/autosave_spec.js new file mode 100644 index 00000000000..12e97f6cdec --- /dev/null +++ b/spec/frontend/lib/utils/autosave_spec.js @@ -0,0 +1,64 @@ +import { clearDraft, getDraft, updateDraft } from '~/lib/utils/autosave'; + +describe('autosave utils', () => { + const autosaveKey = 'dummy-autosave-key'; + const text = 'some dummy text'; + + describe('clearDraft', () => { + beforeEach(() => { + localStorage.setItem(`autosave/${autosaveKey}`, text); + }); + + afterEach(() => { + localStorage.removeItem(`autosave/${autosaveKey}`); + }); + + it('removes the draft from localStorage', () => { + clearDraft(autosaveKey); + + expect(localStorage.getItem(`autosave/${autosaveKey}`)).toBe(null); + }); + }); + + describe('getDraft', () => { + beforeEach(() => { + localStorage.setItem(`autosave/${autosaveKey}`, text); + }); + + afterEach(() => { + localStorage.removeItem(`autosave/${autosaveKey}`); + }); + + it('returns the draft from localStorage', () => { + const result = getDraft(autosaveKey); + + expect(result).toBe(text); + }); + + it('returns null if no entry exists in localStorage', () => { + localStorage.removeItem(`autosave/${autosaveKey}`); + + const result = getDraft(autosaveKey); + + expect(result).toBe(null); + }); + }); + + describe('updateDraft', () => { + beforeEach(() => { + localStorage.setItem(`autosave/${autosaveKey}`, text); + }); + + afterEach(() => { + localStorage.removeItem(`autosave/${autosaveKey}`); + }); + + it('removes the draft from localStorage', () => { + const newText = 'new text'; + + updateDraft(autosaveKey, newText); + + expect(localStorage.getItem(`autosave/${autosaveKey}`)).toBe(newText); + }); + }); +}); diff --git a/spec/javascripts/notes/components/note_form_spec.js b/spec/javascripts/notes/components/note_form_spec.js index 7cc324cfe44..c48f8188105 100644 --- a/spec/javascripts/notes/components/note_form_spec.js +++ b/spec/javascripts/notes/components/note_form_spec.js @@ -5,11 +5,33 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { noteableDataMock, notesDataMock } from '../mock_data'; describe('issue_note_form component', () => { + const dummyAutosaveKey = 'some-autosave-key'; + const dummyDraft = 'dummy draft content'; + let store; let wrapper; let props; + const createComponentWrapper = () => { + const localVue = createLocalVue(); + return shallowMount(NoteForm, { + store, + propsData: props, + // see https://gitlab.com/gitlab-org/gitlab-ce/issues/56317 for the following + localVue, + sync: false, + }); + }; + beforeEach(() => { + spyOnDependency(NoteForm, 'getDraft').and.callFake(key => { + if (key === dummyAutosaveKey) { + return dummyDraft; + } + + return null; + }); + store = createStore(); store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); @@ -20,14 +42,7 @@ describe('issue_note_form component', () => { noteId: '545', }; - const localVue = createLocalVue(); - wrapper = shallowMount(NoteForm, { - store, - propsData: props, - // see https://gitlab.com/gitlab-org/gitlab-ce/issues/56317 for the following - localVue, - sync: false, - }); + wrapper = createComponentWrapper(); }); afterEach(() => { @@ -181,4 +196,67 @@ describe('issue_note_form component', () => { }); }); }); + + describe('with autosaveKey', () => { + beforeEach(() => { + wrapper.destroy(); + }); + + describe('with draft', () => { + beforeEach(done => { + Object.assign(props, { + noteBody: '', + autosaveKey: dummyAutosaveKey, + }); + wrapper = createComponentWrapper(); + + wrapper.vm + .$nextTick() + .then(done) + .catch(done.fail); + }); + + it('displays the draft in textarea', () => { + const textarea = wrapper.find('textarea'); + + expect(textarea.element.value).toBe(dummyDraft); + }); + }); + + describe('without draft', () => { + beforeEach(done => { + Object.assign(props, { + noteBody: '', + autosaveKey: 'some key without draft', + }); + wrapper = createComponentWrapper(); + + wrapper.vm + .$nextTick() + .then(done) + .catch(done.fail); + }); + + it('leaves the textarea empty', () => { + const textarea = wrapper.find('textarea'); + + expect(textarea.element.value).toBe(''); + }); + }); + + it('updates the draft if textarea content changes', () => { + const updateDraftSpy = spyOnDependency(NoteForm, 'updateDraft').and.stub(); + Object.assign(props, { + noteBody: '', + autosaveKey: dummyAutosaveKey, + }); + wrapper = createComponentWrapper(); + const textarea = wrapper.find('textarea'); + const dummyContent = 'some new content'; + + textarea.setValue(dummyContent); + + expect(updateDraftSpy).toHaveBeenCalledWith(dummyAutosaveKey, dummyContent); + }); + }); }); diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js index 2b93fb9fb45..3304c79cdb7 100644 --- a/spec/javascripts/notes/components/noteable_discussion_spec.js +++ b/spec/javascripts/notes/components/noteable_discussion_spec.js @@ -3,6 +3,7 @@ import createStore from '~/notes/stores'; import noteableDiscussion from '~/notes/components/noteable_discussion.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; +import NoteForm from '~/notes/components/note_form.vue'; import '~/behaviors/markdown/render_gfm'; import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data'; import mockDiffFile from '../../diffs/mock_data/diff_file'; @@ -72,7 +73,18 @@ describe('noteable_discussion component', () => { .then(() => wrapper.vm.$nextTick()) .then(() => { expect(wrapper.vm.isReplying).toEqual(true); - expect(wrapper.vm.$refs.noteForm).not.toBeNull(); + + const noteForm = wrapper.find(NoteForm); + + expect(noteForm.exists()).toBe(true); + + const noteFormProps = noteForm.props(); + + expect(noteFormProps.discussion).toBe(discussionMock); + expect(noteFormProps.isEditing).toBe(false); + expect(noteFormProps.line).toBe(null); + expect(noteFormProps.saveButtonTitle).toBe('Comment'); + expect(noteFormProps.autosaveKey).toBe(`Note/Issue/${discussionMock.id}/Reply`); }) .then(done) .catch(done.fail); |