From 911701ae473f28eebf5d1d3cf4d4eef5130f384e Mon Sep 17 00:00:00 2001 From: Paul Gascou-Vaillancourt Date: Wed, 1 May 2019 10:04:07 +0000 Subject: [PATCH] Extract discussion notes into new component - Moved discussion notes out of `NoteableDiscussion` component into a new `DiscussionNotes` component - Wrote Jest tests for the new `DiscussionNotes` component - Updated Jest config for emojis fixtures - Updated Karma tests `NoteableDiscussion` to match its new structure - Convert `DiffDiscussions` tests to use Vue test utils --- .../notes/components/discussion_notes.vue | 155 ++++++++++++++ .../notes/components/noteable_discussion.vue | 190 +++++------------- .../notes/components/notes_app.vue | 3 +- .../javascripts/notes/stores/getters.js | 2 + .../58294-discussion-notes-component.yml | 5 + jest.config.js | 1 + .../notes/components/discussion_notes_spec.js | 139 +++++++++++++ .../diffs/components/diff_discussions_spec.js | 109 +++++----- .../components/noteable_discussion_spec.js | 23 --- 9 files changed, 417 insertions(+), 210 deletions(-) create mode 100644 app/assets/javascripts/notes/components/discussion_notes.vue create mode 100644 changelogs/unreleased/58294-discussion-notes-component.yml create mode 100644 spec/frontend/notes/components/discussion_notes_spec.js diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue new file mode 100644 index 00000000000..5b6163a6214 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -0,0 +1,155 @@ + + + diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 1e47bef7b61..2c549e7abdd 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -5,44 +5,35 @@ 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'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; -import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import noteableNote from './noteable_note.vue'; import noteHeader from './note_header.vue'; -import toggleRepliesWidget from './toggle_replies_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteEditedText from './note_edited_text.vue'; 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 noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; import eventHub from '../event_hub'; +import DiscussionNotes from './discussion_notes.vue'; import DiscussionActions from './discussion_actions.vue'; export default { name: 'NoteableDiscussion', components: { icon, - noteableNote, userAvatarLink, noteHeader, noteSignedOutWidget, noteEditedText, noteForm, - toggleRepliesWidget, - placeholderNote, - placeholderSystemNote, - systemNote, DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'), TimelineEntryItem, + DiscussionNotes, DiscussionActions, }, directives: { @@ -91,6 +82,7 @@ export default { ...mapGetters([ 'convertedDisscussionIds', 'getNoteableData', + 'userCanReply', 'nextUnresolvedDiscussionId', 'unresolvedDiscussionsCount', 'hasUnresolvedDiscussions', @@ -102,21 +94,12 @@ export default { autosaveKey() { return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); }, - canReply() { - return this.getNoteableData.current_user.can_create_note; - }, newNotePath() { return this.getNoteableData.create_note_path; }, - hasReplies() { - return this.discussion.notes.length > 1; - }, firstNote() { return this.discussion.notes.slice(0, 1)[0]; }, - replies() { - return this.discussion.notes.slice(1); - }, lastUpdatedBy() { const { notes } = this.discussion; @@ -222,18 +205,8 @@ export default { return null; }, - commit() { - if (!this.discussion.for_commit) { - return null; - } - - return { - id: this.discussion.commit_id, - url: this.discussion.discussion_path, - }; - }, resolveWithIssuePath() { - return !this.discussionResolved && this.discussion.resolve_with_issue_path; + return !this.discussionResolved ? this.discussion.resolve_with_issue_path : ''; }, }, created() { @@ -252,24 +225,6 @@ export default { 'removeConvertedDiscussion', ]), truncateSha, - componentName(note) { - if (note.isPlaceholderNote) { - if (note.placeholderType === SYSTEM_NOTE) { - return placeholderSystemNote; - } - - return placeholderNote; - } - - if (note.system) { - return systemNote; - } - - return noteableNote; - }, - componentData(note) { - return note.isPlaceholderNote ? note.notes[0] : note; - }, toggleDiscussionHandler() { this.toggleDiscussion({ discussionId: this.discussion.id }); }, @@ -399,97 +354,56 @@ Please check your network connection and try again.`; v-bind="wrapperComponentProps" class="card discussion-wrapper" > -
-
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index e2bd59f7631..0f1976db37d 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -67,6 +67,7 @@ export default { 'isLoading', 'commentsDisabled', 'getNoteableData', + 'userCanReply', ]), noteableType() { return this.noteableData.noteableType; @@ -83,7 +84,7 @@ export default { return this.discussions; }, canReply() { - return this.getNoteableData.current_user.can_create_note && !this.commentsDisabled; + return this.userCanReply && !this.commentsDisabled; }, }, watch: { diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index fcc8889b0c7..2d150e64ef7 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -20,6 +20,8 @@ export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; +export const userCanReply = state => !!state.noteableData.current_user.can_create_note; + export const openState = state => state.noteableData.state; export const getUserData = state => state.userData || {}; diff --git a/changelogs/unreleased/58294-discussion-notes-component.yml b/changelogs/unreleased/58294-discussion-notes-component.yml new file mode 100644 index 00000000000..fbe08360a9a --- /dev/null +++ b/changelogs/unreleased/58294-discussion-notes-component.yml @@ -0,0 +1,5 @@ +--- +title: Extract DiscussionNotes component from NoteableDiscussion +merge_request: 27066 +author: +type: other diff --git a/jest.config.js b/jest.config.js index fdbbe977f0b..0868547e654 100644 --- a/jest.config.js +++ b/jest.config.js @@ -24,6 +24,7 @@ module.exports = { '^helpers(/.*)$': '/spec/frontend/helpers$1', '^vendor(/.*)$': '/vendor/assets/javascripts$1', '\\.(jpg|jpeg|png|svg)$': '/spec/frontend/__mocks__/file_mock.js', + 'emojis(/.*).json': '/fixtures/emojis$1.json', }, collectCoverageFrom: ['/app/assets/javascripts/**/*.{js,vue}'], coverageDirectory: '/coverage-frontend/', diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js new file mode 100644 index 00000000000..392c1b6533e --- /dev/null +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -0,0 +1,139 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import '~/behaviors/markdown/render_gfm'; +import { SYSTEM_NOTE } from '~/notes/constants'; +import DiscussionNotes from '~/notes/components/discussion_notes.vue'; +import NoteableNote from '~/notes/components/noteable_note.vue'; +import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; +import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; +import SystemNote from '~/vue_shared/components/notes/system_note.vue'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import createStore from '~/notes/stores'; +import { + noteableDataMock, + discussionMock, + notesDataMock, +} from '../../../javascripts/notes/mock_data'; + +const localVue = createLocalVue(); + +describe('DiscussionNotes', () => { + let wrapper; + + const createComponent = props => { + const store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + + wrapper = mount(DiscussionNotes, { + localVue, + store, + propsData: { + discussion: discussionMock, + isExpanded: false, + shouldGroupReplies: false, + ...props, + }, + scopedSlots: { + footer: '

showReplies:{{showReplies}}

', + }, + slots: { + 'avatar-badge': '', + }, + sync: false, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('rendering', () => { + it('renders an element for each note in the discussion', () => { + createComponent(); + const notesCount = discussionMock.notes.length; + const els = wrapper.findAll(TimelineEntryItem); + expect(els.length).toBe(notesCount); + }); + + it('renders one element if replies groupping is enabled', () => { + createComponent({ shouldGroupReplies: true }); + const els = wrapper.findAll(TimelineEntryItem); + expect(els.length).toBe(1); + }); + + it('uses proper component to render each note type', () => { + const discussion = { ...discussionMock }; + const notesData = [ + // PlaceholderSystemNote + { + id: 1, + isPlaceholderNote: true, + placeholderType: SYSTEM_NOTE, + notes: [{ body: 'PlaceholderSystemNote' }], + }, + // PlaceholderNote + { + id: 2, + isPlaceholderNote: true, + notes: [{ body: 'PlaceholderNote' }], + }, + // SystemNote + { + id: 3, + system: true, + note: 'SystemNote', + }, + // NoteableNote + discussion.notes[0], + ]; + discussion.notes = notesData; + createComponent({ discussion }); + const notes = wrapper.findAll('.notes > li'); + + expect(notes.at(0).is(PlaceholderSystemNote)).toBe(true); + expect(notes.at(1).is(PlaceholderNote)).toBe(true); + expect(notes.at(2).is(SystemNote)).toBe(true); + expect(notes.at(3).is(NoteableNote)).toBe(true); + }); + + it('renders footer scoped slot with showReplies === true when expanded', () => { + createComponent({ isExpanded: true }); + expect(wrapper.text()).toMatch('showReplies:true'); + }); + + it('renders footer scoped slot with showReplies === false when collapsed', () => { + createComponent({ isExpanded: false }); + expect(wrapper.text()).toMatch('showReplies:false'); + }); + + it('passes down avatar-badge slot content', () => { + createComponent(); + expect(wrapper.find('.avatar-badge-slot-content').exists()).toBe(true); + }); + }); + + describe('componentData', () => { + beforeEach(() => { + createComponent(); + }); + + it('should return first note object for placeholder note', () => { + const data = { + isPlaceholderNote: true, + notes: [{ body: 'hello world!' }], + }; + const note = wrapper.vm.componentData(data); + + expect(note).toEqual(data.notes[0]); + }); + + it('should return given note for nonplaceholder notes', () => { + const data = { + notes: [{ id: 12 }], + }; + const note = wrapper.vm.componentData(data); + + expect(note).toEqual(data); + }); + }); +}); diff --git a/spec/javascripts/diffs/components/diff_discussions_spec.js b/spec/javascripts/diffs/components/diff_discussions_spec.js index 0bc9da5ad0f..f7f0ab83c21 100644 --- a/spec/javascripts/diffs/components/diff_discussions_spec.js +++ b/spec/javascripts/diffs/components/diff_discussions_spec.js @@ -1,90 +1,103 @@ -import Vue from 'vue'; +import { mount, createLocalVue } from '@vue/test-utils'; import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import NoteableDiscussion from '~/notes/components/noteable_discussion.vue'; +import DiscussionNotes from '~/notes/components/discussion_notes.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import { createStore } from '~/mr_notes/stores'; -import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import '~/behaviors/markdown/render_gfm'; import discussionsMockData from '../mock_data/diff_discussions'; +const localVue = createLocalVue(); + describe('DiffDiscussions', () => { - let vm; + let store; + let wrapper; const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)]; - function createComponent(props = {}) { - const store = createStore(); - - vm = createComponentWithStore(Vue.extend(DiffDiscussions), store, { - discussions: getDiscussionsMockData(), - ...props, - }).$mount(); - } + const createComponent = props => { + store = createStore(); + wrapper = mount(localVue.extend(DiffDiscussions), { + store, + propsData: { + discussions: getDiscussionsMockData(), + ...props, + }, + localVue, + sync: false, + }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('template', () => { it('should have notes list', () => { createComponent(); - expect(vm.$el.querySelectorAll('.discussion .note.timeline-entry').length).toEqual(5); + expect(wrapper.find(NoteableDiscussion).exists()).toBe(true); + expect(wrapper.find(DiscussionNotes).exists()).toBe(true); + expect(wrapper.find(DiscussionNotes).findAll(TimelineEntryItem).length).toBe( + discussionsMockData.notes.length, + ); }); }); describe('image commenting', () => { + const findDiffNotesToggle = () => wrapper.find('.js-diff-notes-toggle'); + it('renders collapsible discussion button', () => { createComponent({ shouldCollapseDiscussions: true }); + const diffNotesToggle = findDiffNotesToggle(); - expect(vm.$el.querySelector('.js-diff-notes-toggle')).not.toBe(null); - expect(vm.$el.querySelector('.js-diff-notes-toggle svg')).not.toBe(null); - expect(vm.$el.querySelector('.js-diff-notes-toggle').classList).toContain( - 'diff-notes-collapse', - ); + expect(diffNotesToggle.exists()).toBe(true); + expect(diffNotesToggle.find(Icon).exists()).toBe(true); + expect(diffNotesToggle.classes('diff-notes-collapse')).toBe(true); }); it('dispatches toggleDiscussion when clicking collapse button', () => { createComponent({ shouldCollapseDiscussions: true }); + spyOn(wrapper.vm.$store, 'dispatch').and.stub(); + const diffNotesToggle = findDiffNotesToggle(); + diffNotesToggle.trigger('click'); - spyOn(vm.$store, 'dispatch').and.stub(); - - vm.$el.querySelector('.js-diff-notes-toggle').click(); - - expect(vm.$store.dispatch).toHaveBeenCalledWith('toggleDiscussion', { - discussionId: vm.discussions[0].id, + expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('toggleDiscussion', { + discussionId: discussionsMockData.id, }); }); - it('renders expand button when discussion is collapsed', done => { - createComponent({ shouldCollapseDiscussions: true }); + it('renders expand button when discussion is collapsed', () => { + const discussions = getDiscussionsMockData(); + discussions[0].expanded = false; + createComponent({ discussions, shouldCollapseDiscussions: true }); + const diffNotesToggle = findDiffNotesToggle(); - vm.discussions[0].expanded = false; - - vm.$nextTick(() => { - expect(vm.$el.querySelector('.js-diff-notes-toggle').textContent.trim()).toBe('1'); - expect(vm.$el.querySelector('.js-diff-notes-toggle').className).toContain( - 'btn-transparent badge badge-pill', - ); - - done(); - }); + expect(diffNotesToggle.text().trim()).toBe('1'); + expect(diffNotesToggle.classes()).toEqual( + jasmine.arrayContaining(['btn-transparent', 'badge', 'badge-pill']), + ); }); - it('hides discussion when collapsed', done => { - createComponent({ shouldCollapseDiscussions: true }); + it('hides discussion when collapsed', () => { + const discussions = getDiscussionsMockData(); + discussions[0].expanded = false; + createComponent({ discussions, shouldCollapseDiscussions: true }); - vm.discussions[0].expanded = false; - - vm.$nextTick(() => { - expect(vm.$el.querySelector('.note-discussion').style.display).toBe('none'); - - done(); - }); + expect(wrapper.find(NoteableDiscussion).isVisible()).toBe(false); }); it('renders badge on avatar', () => { - createComponent({ renderAvatarBadge: true, discussions: [{ ...discussionsMockData }] }); + createComponent({ renderAvatarBadge: true }); + const noteableDiscussion = wrapper.find(NoteableDiscussion); - expect(vm.$el.querySelector('.user-avatar-link .badge-pill')).not.toBe(null); - expect(vm.$el.querySelector('.user-avatar-link .badge-pill').textContent.trim()).toBe('1'); + expect(noteableDiscussion.find('.badge-pill').exists()).toBe(true); + expect( + noteableDiscussion + .find('.badge-pill') + .text() + .trim(), + ).toBe('1'); }); }); }); diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js index 3304c79cdb7..efa864e7d00 100644 --- a/spec/javascripts/notes/components/noteable_discussion_spec.js +++ b/spec/javascripts/notes/components/noteable_discussion_spec.js @@ -130,29 +130,6 @@ describe('noteable_discussion component', () => { }); }); - describe('componentData', () => { - it('should return first note object for placeholder note', () => { - const data = { - isPlaceholderNote: true, - notes: [{ body: 'hello world!' }], - }; - - const note = wrapper.vm.componentData(data); - - expect(note).toEqual(data.notes[0]); - }); - - it('should return given note for nonplaceholder notes', () => { - const data = { - notes: [{ id: 12 }], - }; - - const note = wrapper.vm.componentData(data); - - expect(note).toEqual(data); - }); - }); - describe('action text', () => { const commitId = 'razupaltuff'; const truncatedCommitId = commitId.substr(0, 8);