diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-02 18:08:11 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-02 18:08:11 +0000 |
commit | 8a7efa45c38ed3200d173d2c3207a8154e583c16 (patch) | |
tree | 1bb4d579b95c79aae4946a06fefa089e5549b722 /spec/frontend | |
parent | 53b1f4eaa2a451aaba908a5fee7ce97a930021ac (diff) | |
download | gitlab-ce-8a7efa45c38ed3200d173d2c3207a8154e583c16.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend')
19 files changed, 1663 insertions, 8 deletions
diff --git a/spec/frontend/__mocks__/sortablejs/index.js b/spec/frontend/__mocks__/sortablejs/index.js index a1166d21561..5039af54542 100644 --- a/spec/frontend/__mocks__/sortablejs/index.js +++ b/spec/frontend/__mocks__/sortablejs/index.js @@ -1,4 +1,4 @@ -import Sortablejs from 'sortablejs'; +const Sortablejs = jest.genMockFromModule('sortablejs'); export default Sortablejs; export const Sortable = Sortablejs; diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js new file mode 100644 index 00000000000..7cf6ec913b4 --- /dev/null +++ b/spec/frontend/boards/components/board_column_spec.js @@ -0,0 +1,172 @@ +import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; + +import Board from '~/boards/components/board_column.vue'; +import List from '~/boards/models/list'; +import { ListType } from '~/boards/constants'; +import axios from '~/lib/utils/axios_utils'; + +import { TEST_HOST } from 'helpers/test_constants'; +import { listObj } from 'jest/boards/mock_data'; + +describe('Board Column Component', () => { + let wrapper; + let axiosMock; + + beforeEach(() => { + window.gon = {}; + axiosMock = new AxiosMockAdapter(axios); + axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); + }); + + afterEach(() => { + axiosMock.restore(); + + wrapper.destroy(); + + localStorage.clear(); + }); + + const createComponent = ({ + listType = ListType.backlog, + collapsed = false, + withLocalStorage = true, + } = {}) => { + const boardId = '1'; + + const listMock = { + ...listObj, + list_type: listType, + collapsed, + }; + + if (listType === ListType.assignee) { + delete listMock.label; + listMock.user = {}; + } + + // Making List reactive + const list = Vue.observable(new List(listMock)); + + if (withLocalStorage) { + localStorage.setItem( + `boards.${boardId}.${list.type}.${list.id}.expanded`, + (!collapsed).toString(), + ); + } + + wrapper = shallowMount(Board, { + propsData: { + boardId, + disabled: false, + issueLinkBase: '/', + rootPath: '/', + list, + }, + }); + }; + + const isExpandable = () => wrapper.classes('is-expandable'); + const isCollapsed = () => wrapper.classes('is-collapsed'); + + const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); + + describe('Add issue button', () => { + const hasNoAddButton = [ListType.promotion, ListType.blank, ListType.closed]; + const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; + + it.each(hasNoAddButton)('does not render when List Type is `%s`', listType => { + createComponent({ listType }); + + expect(findAddIssueButton().exists()).toBe(false); + }); + + it.each(hasAddButton)('does render when List Type is `%s`', listType => { + createComponent({ listType }); + + expect(findAddIssueButton().exists()).toBe(true); + }); + + it('has a test for each list type', () => { + Object.values(ListType).forEach(value => { + expect([...hasAddButton, ...hasNoAddButton]).toContain(value); + }); + }); + + it('does render when logged out', () => { + createComponent(); + + expect(findAddIssueButton().exists()).toBe(true); + }); + }); + + describe('Given different list types', () => { + it('is expandable when List Type is `backlog`', () => { + createComponent({ listType: ListType.backlog }); + + expect(isExpandable()).toBe(true); + }); + }); + + describe('expanding / collapsing the column', () => { + it('does not collapse when clicking the header', () => { + createComponent(); + expect(isCollapsed()).toBe(false); + wrapper.find('.board-header').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(false); + }); + }); + + it('collapses expanded Column when clicking the collapse icon', () => { + createComponent(); + expect(wrapper.vm.list.isExpanded).toBe(true); + wrapper.find('.board-title-caret').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(true); + }); + }); + + it('expands collapsed Column when clicking the expand icon', () => { + createComponent({ collapsed: true }); + expect(isCollapsed()).toBe(true); + wrapper.find('.board-title-caret').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(false); + }); + }); + + it("when logged in it calls list update and doesn't set localStorage", () => { + jest.spyOn(List.prototype, 'update'); + window.gon.current_user_id = 1; + + createComponent({ withLocalStorage: false }); + + wrapper.find('.board-title-caret').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); + }); + }); + + it("when logged out it doesn't call list update and sets localStorage", () => { + jest.spyOn(List.prototype, 'update'); + + createComponent(); + + wrapper.find('.board-title-caret').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.list.update).toHaveBeenCalledTimes(0); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe( + String(wrapper.vm.list.isExpanded), + ); + }); + }); + }); +}); diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js index c0dd5afe498..b30281f8df5 100644 --- a/spec/frontend/boards/list_spec.js +++ b/spec/frontend/boards/list_spec.js @@ -56,7 +56,7 @@ describe('List model', () => { label: { id: 1, title: 'test', - color: 'red', + color: '#ff0000', text_color: 'white', }, }); @@ -64,8 +64,7 @@ describe('List model', () => { expect(list.id).toBe(listObj.id); expect(list.type).toBe('label'); expect(list.position).toBe(0); - expect(list.label.color).toBe('red'); - expect(list.label.textColor).toBe('white'); + expect(list.label).toEqual(listObj.label); }); }); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index fa4154676a2..97d49de6f2e 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -15,7 +15,7 @@ export const listObj = { label: { id: 5000, title: 'Test', - color: 'red', + color: '#ff0000', description: 'testing;', textColor: 'white', }, @@ -30,7 +30,7 @@ export const listObjDuplicate = { label: { id: listObj.label.id, title: 'Test', - color: 'red', + color: '#ff0000', description: 'testing;', }, }; diff --git a/spec/frontend/lib/utils/file_upload_spec.js b/spec/frontend/lib/utils/file_upload_spec.js index 1255d6fc14f..1dff5d4f925 100644 --- a/spec/frontend/lib/utils/file_upload_spec.js +++ b/spec/frontend/lib/utils/file_upload_spec.js @@ -1,4 +1,4 @@ -import fileUpload from '~/lib/utils/file_upload'; +import fileUpload, { getFilename } from '~/lib/utils/file_upload'; describe('File upload', () => { beforeEach(() => { @@ -62,3 +62,15 @@ describe('File upload', () => { expect(input.click).not.toHaveBeenCalled(); }); }); + +describe('getFilename', () => { + it('returns first value correctly', () => { + const event = { + clipboardData: { + getData: () => 'test.png\rtest.txt', + }, + }; + + expect(getFilename(event)).toBe('test.png'); + }); +}); diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js index 4c76f9c50fb..9162bee2078 100644 --- a/spec/frontend/notes/components/diff_discussion_header_spec.js +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import createStore from '~/notes/stores'; import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue'; -import { discussionMock } from '../../../javascripts/notes/mock_data'; +import { discussionMock } from '../mock_data'; import mockDiffFile from '../../diffs/mock_data/diff_discussions'; const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; diff --git a/spec/frontend/notes/components/diff_with_note_spec.js b/spec/frontend/notes/components/diff_with_note_spec.js new file mode 100644 index 00000000000..d6d42e1988d --- /dev/null +++ b/spec/frontend/notes/components/diff_with_note_spec.js @@ -0,0 +1,86 @@ +import { mount } from '@vue/test-utils'; +import DiffWithNote from '~/notes/components/diff_with_note.vue'; +import { createStore } from '~/mr_notes/stores'; + +const discussionFixture = 'merge_requests/diff_discussion.json'; +const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json'; + +describe('diff_with_note', () => { + let store; + let wrapper; + + const selectors = { + get diffTable() { + return wrapper.find('.diff-content table'); + }, + get diffRows() { + return wrapper.findAll('.diff-content .line_holder'); + }, + get noteRow() { + return wrapper.find('.diff-content .notes_holder'); + }, + }; + + beforeEach(() => { + store = createStore(); + store.replaceState({ + ...store.state, + notes: { + noteableData: { + current_user: {}, + }, + }, + }); + }); + + describe('text diff', () => { + beforeEach(() => { + const diffDiscussion = getJSONFixture(discussionFixture)[0]; + + wrapper = mount(DiffWithNote, { + propsData: { + discussion: diffDiscussion, + }, + store, + }); + }); + + it('removes trailing "+" char', () => { + const richText = wrapper.vm.$el + .querySelectorAll('.line_holder')[4] + .querySelector('.line_content').textContent[0]; + + expect(richText).not.toEqual('+'); + }); + + it('removes trailing "-" char', () => { + const richText = wrapper.vm.$el.querySelector('#LC13').parentNode.textContent[0]; + + expect(richText).not.toEqual('-'); + }); + + it('shows text diff', () => { + expect(wrapper.classes('text-file')).toBe(true); + expect(selectors.diffTable.exists()).toBe(true); + }); + + it('shows diff lines', () => { + expect(selectors.diffRows.length).toBe(12); + }); + + it('shows notes row', () => { + expect(selectors.noteRow.exists()).toBe(true); + }); + }); + + describe('image diff', () => { + beforeEach(() => { + const imageDiscussion = getJSONFixture(imageDiscussionFixture)[0]; + wrapper = mount(DiffWithNote, { propsData: { discussion: imageDiscussion }, store }); + }); + + it('shows image diff', () => { + expect(selectors.diffTable.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js new file mode 100644 index 00000000000..b8d2d721443 --- /dev/null +++ b/spec/frontend/notes/components/discussion_filter_spec.js @@ -0,0 +1,219 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; + +import { createLocalVue, mount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; + +import axios from '~/lib/utils/axios_utils'; +import notesModule from '~/notes/stores/modules'; +import DiscussionFilter from '~/notes/components/discussion_filter.vue'; +import { DISCUSSION_FILTERS_DEFAULT_VALUE, DISCUSSION_FILTER_TYPES } from '~/notes/constants'; + +import { discussionFiltersMock, discussionMock } from '../mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +const DISCUSSION_PATH = `${TEST_HOST}/example`; + +describe('DiscussionFilter component', () => { + let wrapper; + let store; + let eventHub; + let mock; + + const filterDiscussion = jest.fn(); + + const mountComponent = () => { + const discussions = [ + { + ...discussionMock, + id: discussionMock.id, + notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }], + }, + ]; + + const defaultStore = { ...notesModule() }; + + store = new Vuex.Store({ + ...defaultStore, + actions: { + ...defaultStore.actions, + filterDiscussion, + }, + }); + + store.state.notesData.discussionsPath = DISCUSSION_PATH; + + store.state.discussions = discussions; + + return mount(DiscussionFilter, { + store, + propsData: { + filters: discussionFiltersMock, + selectedValue: DISCUSSION_FILTERS_DEFAULT_VALUE, + }, + localVue, + }); + }; + + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + + // We are mocking the discussions retrieval, + // as it doesn't matter for our tests here + mock.onGet(DISCUSSION_PATH).reply(200, ''); + window.mrTabs = undefined; + wrapper = mountComponent(); + }); + + afterEach(() => { + wrapper.vm.$destroy(); + mock.restore(); + }); + + it('renders the all filters', () => { + expect(wrapper.findAll('.dropdown-menu li').length).toBe(discussionFiltersMock.length); + }); + + it('renders the default selected item', () => { + expect( + wrapper + .find('#discussion-filter-dropdown') + .text() + .trim(), + ).toBe(discussionFiltersMock[0].title); + }); + + it('updates to the selected item', () => { + const filterItem = wrapper.find( + `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`, + ); + + filterItem.trigger('click'); + + expect(wrapper.vm.currentFilter.title).toBe(filterItem.text().trim()); + }); + + it('only updates when selected filter changes', () => { + wrapper + .find(`.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`) + .trigger('click'); + + expect(filterDiscussion).not.toHaveBeenCalled(); + }); + + it('disables commenting when "Show history only" filter is applied', () => { + const filterItem = wrapper.find( + `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`, + ); + filterItem.trigger('click'); + + expect(wrapper.vm.$store.state.commentsDisabled).toBe(true); + }); + + it('enables commenting when "Show history only" filter is not applied', () => { + const filterItem = wrapper.find( + `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`, + ); + filterItem.trigger('click'); + + expect(wrapper.vm.$store.state.commentsDisabled).toBe(false); + }); + + it('renders a dropdown divider for the default filter', () => { + const defaultFilter = wrapper.findAll( + `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] > *`, + ); + + expect(defaultFilter.at(defaultFilter.length - 1).classes('dropdown-divider')).toBe(true); + }); + + describe('Merge request tabs', () => { + eventHub = new Vue(); + + beforeEach(() => { + window.mrTabs = { + eventHub, + currentTab: 'show', + }; + + wrapper = mountComponent(); + }); + + afterEach(() => { + window.mrTabs = undefined; + }); + + it('only renders when discussion tab is active', done => { + eventHub.$emit('MergeRequestTabChange', 'commit'); + + wrapper.vm.$nextTick(() => { + expect(wrapper.isEmpty()).toBe(true); + done(); + }); + }); + }); + + describe('URL with Links to notes', () => { + afterEach(() => { + window.location.hash = ''; + }); + + it('updates the filter when the URL links to a note', done => { + window.location.hash = `note_${discussionMock.notes[0].id}`; + wrapper.vm.currentValue = discussionFiltersMock[2].value; + wrapper.vm.handleLocationHash(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE); + done(); + }); + }); + + it('does not update the filter when the current filter is "Show all activity"', done => { + window.location.hash = `note_${discussionMock.notes[0].id}`; + wrapper.vm.handleLocationHash(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE); + done(); + }); + }); + + it('only updates filter when the URL links to a note', done => { + window.location.hash = `testing123`; + wrapper.vm.handleLocationHash(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE); + done(); + }); + }); + + it('fetches discussions when there is a hash', done => { + window.location.hash = `note_${discussionMock.notes[0].id}`; + wrapper.vm.currentValue = discussionFiltersMock[2].value; + jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {}); + wrapper.vm.handleLocationHash(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.selectFilter).toHaveBeenCalled(); + done(); + }); + }); + + it('does not fetch discussions when there is no hash', done => { + window.location.hash = ''; + jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {}); + wrapper.vm.handleLocationHash(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.selectFilter).not.toHaveBeenCalled(); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js b/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js new file mode 100644 index 00000000000..4348445f7ca --- /dev/null +++ b/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js @@ -0,0 +1,30 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { TEST_HOST } from 'spec/test_constants'; +import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; + +const localVue = createLocalVue(); + +describe('ResolveWithIssueButton', () => { + let wrapper; + const url = `${TEST_HOST}/hello-world/`; + + beforeEach(() => { + wrapper = shallowMount(ResolveWithIssueButton, { + localVue, + propsData: { + url, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('it should have a link with the provided link property as href', () => { + const button = wrapper.find(GlButton); + + expect(button.attributes().href).toBe(url); + }); +}); diff --git a/spec/frontend/notes/components/note_actions/reply_button_spec.js b/spec/frontend/notes/components/note_actions/reply_button_spec.js new file mode 100644 index 00000000000..720ab10b270 --- /dev/null +++ b/spec/frontend/notes/components/note_actions/reply_button_spec.js @@ -0,0 +1,29 @@ +import Vuex from 'vuex'; +import { createLocalVue, mount } from '@vue/test-utils'; +import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ReplyButton', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(localVue.extend(ReplyButton), { + localVue, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('emits startReplying on click', () => { + const button = wrapper.find({ ref: 'button' }); + + button.trigger('click'); + + expect(wrapper.emitted().startReplying).toBeTruthy(); + expect(wrapper.emitted().startReplying.length).toBe(1); + }); +}); diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js new file mode 100644 index 00000000000..5d13f587ca7 --- /dev/null +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -0,0 +1,160 @@ +import Vue from 'vue'; +import { shallowMount, createLocalVue, createWrapper } from '@vue/test-utils'; +import { TEST_HOST } from 'spec/test_constants'; +import createStore from '~/notes/stores'; +import noteActions from '~/notes/components/note_actions.vue'; +import { userDataMock } from '../mock_data'; + +describe('noteActions', () => { + let wrapper; + let store; + let props; + + const shallowMountNoteActions = propsData => { + const localVue = createLocalVue(); + return shallowMount(localVue.extend(noteActions), { + store, + propsData, + localVue, + }); + }; + + beforeEach(() => { + store = createStore(); + props = { + accessLevel: 'Maintainer', + authorId: 26, + canDelete: true, + canEdit: true, + canAwardEmoji: true, + canReportAsAbuse: true, + noteId: '539', + noteUrl: `${TEST_HOST}/group/project/-/merge_requests/1#note_1`, + reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`, + showReply: false, + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('user is logged in', () => { + beforeEach(() => { + store.dispatch('setUserData', userDataMock); + + wrapper = shallowMountNoteActions(props); + }); + + it('should render access level badge', () => { + expect( + wrapper + .find('.note-role') + .text() + .trim(), + ).toEqual(props.accessLevel); + }); + + it('should render emoji link', () => { + expect(wrapper.find('.js-add-award').exists()).toBe(true); + expect(wrapper.find('.js-add-award').attributes('data-position')).toBe('right'); + }); + + describe('actions dropdown', () => { + it('should be possible to edit the comment', () => { + expect(wrapper.find('.js-note-edit').exists()).toBe(true); + }); + + it('should be possible to report abuse to admin', () => { + expect(wrapper.find(`a[href="${props.reportAbusePath}"]`).exists()).toBe(true); + }); + + it('should be possible to copy link to a note', () => { + expect(wrapper.find('.js-btn-copy-note-link').exists()).toBe(true); + }); + + it('should not show copy link action when `noteUrl` prop is empty', done => { + wrapper.setProps({ + ...props, + noteUrl: '', + }); + + Vue.nextTick() + .then(() => { + expect(wrapper.find('.js-btn-copy-note-link').exists()).toBe(false); + }) + .then(done) + .catch(done.fail); + }); + + it('should be possible to delete comment', () => { + expect(wrapper.find('.js-note-delete').exists()).toBe(true); + }); + + it('closes tooltip when dropdown opens', done => { + wrapper.find('.more-actions-toggle').trigger('click'); + + const rootWrapper = createWrapper(wrapper.vm.$root); + Vue.nextTick() + .then(() => { + const emitted = Object.keys(rootWrapper.emitted()); + + expect(emitted).toEqual(['bv::hide::tooltip']); + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('user is not logged in', () => { + beforeEach(() => { + store.dispatch('setUserData', {}); + wrapper = shallowMountNoteActions({ + ...props, + canDelete: false, + canEdit: false, + canAwardEmoji: false, + canReportAsAbuse: false, + }); + }); + + it('should not render emoji link', () => { + expect(wrapper.find('.js-add-award').exists()).toBe(false); + }); + + it('should not render actions dropdown', () => { + expect(wrapper.find('.more-actions').exists()).toBe(false); + }); + }); + + describe('for showReply = true', () => { + beforeEach(() => { + wrapper = shallowMountNoteActions({ + ...props, + showReply: true, + }); + }); + + it('shows a reply button', () => { + const replyButton = wrapper.find({ ref: 'replyButton' }); + + expect(replyButton.exists()).toBe(true); + }); + }); + + describe('for showReply = false', () => { + beforeEach(() => { + wrapper = shallowMountNoteActions({ + ...props, + showReply: false, + }); + }); + + it('does not show a reply button', () => { + const replyButton = wrapper.find({ ref: 'replyButton' }); + + expect(replyButton.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/notes/components/note_awards_list_spec.js b/spec/frontend/notes/components/note_awards_list_spec.js new file mode 100644 index 00000000000..822b1f9efce --- /dev/null +++ b/spec/frontend/notes/components/note_awards_list_spec.js @@ -0,0 +1,163 @@ +import Vue from 'vue'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import createStore from '~/notes/stores'; +import awardsNote from '~/notes/components/note_awards_list.vue'; +import { noteableDataMock, notesDataMock } from '../mock_data'; +import { TEST_HOST } from 'jest/helpers/test_constants'; + +describe('note_awards_list component', () => { + let store; + let vm; + let awardsMock; + let mock; + + const toggleAwardPath = `${TEST_HOST}/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji`; + + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + + mock.onPost(toggleAwardPath).reply(200, ''); + + const Component = Vue.extend(awardsNote); + + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + awardsMock = [ + { + name: 'flag_tz', + user: { id: 1, name: 'Administrator', username: 'root' }, + }, + { + name: 'cartwheel_tone3', + user: { id: 12, name: 'Bobbie Stehr', username: 'erin' }, + }, + ]; + + vm = new Component({ + store, + propsData: { + awards: awardsMock, + noteAuthorId: 2, + noteId: '545', + canAwardEmoji: true, + toggleAwardPath, + }, + }).$mount(); + }); + + afterEach(() => { + mock.restore(); + vm.$destroy(); + }); + + it('should render awarded emojis', () => { + expect(vm.$el.querySelector('.js-awards-block button [data-name="flag_tz"]')).toBeDefined(); + expect( + vm.$el.querySelector('.js-awards-block button [data-name="cartwheel_tone3"]'), + ).toBeDefined(); + }); + + it('should be possible to remove awarded emoji', () => { + jest.spyOn(vm, 'handleAward'); + jest.spyOn(vm, 'toggleAwardRequest'); + vm.$el.querySelector('.js-awards-block button').click(); + + expect(vm.handleAward).toHaveBeenCalledWith('flag_tz'); + expect(vm.toggleAwardRequest).toHaveBeenCalled(); + }); + + it('should be possible to add new emoji', () => { + expect(vm.$el.querySelector('.js-add-award')).toBeDefined(); + }); + + describe('when the user name contains special HTML characters', () => { + const createAwardEmoji = (_, index) => ({ + name: 'art', + user: { id: index, name: `&<>"\`'-${index}`, username: `user-${index}` }, + }); + + const mountComponent = () => { + const Component = Vue.extend(awardsNote); + vm = new Component({ + store, + propsData: { + awards: awardsMock, + noteAuthorId: 0, + noteId: '545', + canAwardEmoji: true, + toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', + }, + }).$mount(); + }; + + const findTooltip = () => + vm.$el.querySelector('[data-original-title]').getAttribute('data-original-title'); + + it('should only escape & and " characters', () => { + awardsMock = [...new Array(1)].map(createAwardEmoji); + mountComponent(); + const escapedName = awardsMock[0].user.name.replace(/&/g, '&').replace(/"/g, '"'); + + expect(vm.$el.querySelector('[data-original-title]').outerHTML).toContain(escapedName); + }); + + it('should not escape special HTML characters twice when only 1 person awarded', () => { + awardsMock = [...new Array(1)].map(createAwardEmoji); + mountComponent(); + + awardsMock.forEach(award => { + expect(findTooltip()).toContain(award.user.name); + }); + }); + + it('should not escape special HTML characters twice when 2 people awarded', () => { + awardsMock = [...new Array(2)].map(createAwardEmoji); + mountComponent(); + + awardsMock.forEach(award => { + expect(findTooltip()).toContain(award.user.name); + }); + }); + + it('should not escape special HTML characters twice when more than 10 people awarded', () => { + awardsMock = [...new Array(11)].map(createAwardEmoji); + mountComponent(); + + // Testing only the first 10 awards since 11 onward will not be displayed. + awardsMock.slice(0, 10).forEach(award => { + expect(findTooltip()).toContain(award.user.name); + }); + }); + }); + + describe('when the user cannot award emoji', () => { + beforeEach(() => { + const Component = Vue.extend(awardsNote); + + vm = new Component({ + store, + propsData: { + awards: awardsMock, + noteAuthorId: 2, + noteId: '545', + canAwardEmoji: false, + toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', + }, + }).$mount(); + }); + + it('should not be possible to remove awarded emoji', () => { + jest.spyOn(vm, 'toggleAwardRequest'); + + vm.$el.querySelector('.js-awards-block button').click(); + + expect(vm.toggleAwardRequest).not.toHaveBeenCalled(); + }); + + it('should not be possible to add new emoji', () => { + expect(vm.$el.querySelector('.js-add-award')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js new file mode 100644 index 00000000000..efad0785afe --- /dev/null +++ b/spec/frontend/notes/components/note_body_spec.js @@ -0,0 +1,57 @@ +import Vue from 'vue'; +import createStore from '~/notes/stores'; +import noteBody from '~/notes/components/note_body.vue'; +import { noteableDataMock, notesDataMock, note } from '../mock_data'; + +describe('issue_note_body component', () => { + let store; + let vm; + + beforeEach(() => { + const Component = Vue.extend(noteBody); + + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + + vm = new Component({ + store, + propsData: { + note, + canEdit: true, + canAwardEmoji: true, + }, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render the note', () => { + expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); + }); + + it('should render awards list', () => { + expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).not.toBeNull(); + expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).not.toBeNull(); + }); + + describe('isEditing', () => { + beforeEach(done => { + vm.isEditing = true; + Vue.nextTick(done); + }); + + it('renders edit form', () => { + expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull(); + }); + + it('adds autosave', () => { + const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`; + + expect(vm.autosave).toExist(); + expect(vm.autosave.key).toEqual(autosaveKey); + }); + }); +}); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js new file mode 100644 index 00000000000..bccac03126c --- /dev/null +++ b/spec/frontend/notes/components/note_form_spec.js @@ -0,0 +1,248 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import createStore from '~/notes/stores'; +import NoteForm from '~/notes/components/note_form.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { noteableDataMock, notesDataMock } from '../mock_data'; + +import { getDraft, updateDraft } from '~/lib/utils/autosave'; + +jest.mock('~/lib/utils/autosave'); + +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(localVue.extend(NoteForm), { + store, + propsData: props, + // see https://gitlab.com/gitlab-org/gitlab-foss/issues/56317 for the following + localVue, + }); + }; + + beforeEach(() => { + getDraft.mockImplementation(key => { + if (key === dummyAutosaveKey) { + return dummyDraft; + } + + return null; + }); + + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + + props = { + isEditing: false, + noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.', + noteId: '545', + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('noteHash', () => { + beforeEach(() => { + wrapper = createComponentWrapper(); + }); + + it('returns note hash string based on `noteId`', () => { + expect(wrapper.vm.noteHash).toBe(`#note_${props.noteId}`); + }); + + it('return note hash as `#` when `noteId` is empty', () => { + wrapper.setProps({ + ...props, + noteId: '', + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.noteHash).toBe('#'); + }); + }); + }); + + describe('conflicts editing', () => { + beforeEach(() => { + wrapper = createComponentWrapper(); + }); + + it('should show conflict message if note changes outside the component', () => { + wrapper.setProps({ + ...props, + isEditing: true, + noteBody: 'Foo', + }); + + const message = + 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.'; + + return wrapper.vm.$nextTick().then(() => { + const conflictWarning = wrapper.find('.js-conflict-edit-warning'); + + expect(conflictWarning.exists()).toBe(true); + expect( + conflictWarning + .text() + .replace(/\s+/g, ' ') + .trim(), + ).toBe(message); + }); + }); + }); + + describe('form', () => { + beforeEach(() => { + wrapper = createComponentWrapper(); + }); + + it('should render text area with placeholder', () => { + const textarea = wrapper.find('textarea'); + + expect(textarea.attributes('placeholder')).toEqual( + 'Write a comment or drag your files hereā¦', + ); + }); + + it('should link to markdown docs', () => { + const { markdownDocsPath } = notesDataMock; + const markdownField = wrapper.find(MarkdownField); + const markdownFieldProps = markdownField.props(); + + expect(markdownFieldProps.markdownDocsPath).toBe(markdownDocsPath); + }); + + describe('keyboard events', () => { + let textarea; + + beforeEach(() => { + textarea = wrapper.find('textarea'); + textarea.setValue('Foo'); + }); + + describe('up', () => { + it('should ender edit mode', () => { + // TODO: do not spy on vm + jest.spyOn(wrapper.vm, 'editMyLastNote'); + + textarea.trigger('keydown.up'); + + expect(wrapper.vm.editMyLastNote).toHaveBeenCalled(); + }); + }); + + describe('enter', () => { + it('should save note when cmd+enter is pressed', () => { + textarea.trigger('keydown.enter', { metaKey: true }); + + const { handleFormUpdate } = wrapper.emitted(); + + expect(handleFormUpdate.length).toBe(1); + }); + + it('should save note when ctrl+enter is pressed', () => { + textarea.trigger('keydown.enter', { ctrlKey: true }); + + const { handleFormUpdate } = wrapper.emitted(); + + expect(handleFormUpdate.length).toBe(1); + }); + }); + }); + + describe('actions', () => { + it('should be possible to cancel', () => { + // TODO: do not spy on vm + jest.spyOn(wrapper.vm, 'cancelHandler'); + wrapper.setProps({ + ...props, + isEditing: true, + }); + + return wrapper.vm.$nextTick().then(() => { + const cancelButton = wrapper.find('.note-edit-cancel'); + cancelButton.trigger('click'); + + expect(wrapper.vm.cancelHandler).toHaveBeenCalled(); + }); + }); + + it('should be possible to update the note', () => { + wrapper.setProps({ + ...props, + isEditing: true, + }); + + return wrapper.vm.$nextTick().then(() => { + const textarea = wrapper.find('textarea'); + textarea.setValue('Foo'); + const saveButton = wrapper.find('.js-vue-issue-save'); + saveButton.trigger('click'); + + expect(wrapper.vm.isSubmitting).toBe(true); + }); + }); + }); + }); + + describe('with autosaveKey', () => { + describe('with draft', () => { + beforeEach(() => { + Object.assign(props, { + noteBody: '', + autosaveKey: dummyAutosaveKey, + }); + wrapper = createComponentWrapper(); + + return wrapper.vm.$nextTick(); + }); + + it('displays the draft in textarea', () => { + const textarea = wrapper.find('textarea'); + + expect(textarea.element.value).toBe(dummyDraft); + }); + }); + + describe('without draft', () => { + beforeEach(() => { + Object.assign(props, { + noteBody: '', + autosaveKey: 'some key without draft', + }); + wrapper = createComponentWrapper(); + + return wrapper.vm.$nextTick(); + }); + + it('leaves the textarea empty', () => { + const textarea = wrapper.find('textarea'); + + expect(textarea.element.value).toBe(''); + }); + }); + + it('updates the draft if textarea content changes', () => { + Object.assign(props, { + noteBody: '', + autosaveKey: dummyAutosaveKey, + }); + wrapper = createComponentWrapper(); + const textarea = wrapper.find('textarea'); + const dummyContent = 'some new content'; + + textarea.setValue(dummyContent); + + expect(updateDraft).toHaveBeenCalledWith(dummyAutosaveKey, dummyContent); + }); + }); +}); diff --git a/spec/frontend/notes/components/note_signed_out_widget_spec.js b/spec/frontend/notes/components/note_signed_out_widget_spec.js new file mode 100644 index 00000000000..e217a2caa73 --- /dev/null +++ b/spec/frontend/notes/components/note_signed_out_widget_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import noteSignedOut from '~/notes/components/note_signed_out_widget.vue'; +import createStore from '~/notes/stores'; +import { notesDataMock } from '../mock_data'; + +describe('note_signed_out_widget component', () => { + let store; + let vm; + + beforeEach(() => { + const Component = Vue.extend(noteSignedOut); + store = createStore(); + store.dispatch('setNotesData', notesDataMock); + + vm = new Component({ + store, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render sign in link provided in the store', () => { + expect(vm.$el.querySelector(`a[href="${notesDataMock.newSessionPath}"]`).textContent).toEqual( + 'sign in', + ); + }); + + it('should render register link provided in the store', () => { + expect(vm.$el.querySelector(`a[href="${notesDataMock.registerPath}"]`).textContent).toEqual( + 'register', + ); + }); + + it('should render information text', () => { + expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual( + 'Please register or sign in to reply', + ); + }); +}); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js new file mode 100644 index 00000000000..b91f599f158 --- /dev/null +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -0,0 +1,187 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +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, + loggedOutnoteableData, + userDataMock, +} from '../mock_data'; +import mockDiffFile from 'jest/diffs/mock_data/diff_file'; +import { trimText } from 'helpers/text_helper'; + +const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; + +const localVue = createLocalVue(); + +describe('noteable_discussion component', () => { + let store; + let wrapper; + let originalGon; + + preloadFixtures(discussionWithTwoUnresolvedNotes); + + beforeEach(() => { + window.mrTabs = {}; + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + + wrapper = mount(localVue.extend(noteableDiscussion), { + store, + propsData: { discussion: discussionMock }, + localVue, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should not render thread header for non diff threads', () => { + expect(wrapper.find('.discussion-header').exists()).toBe(false); + }); + + it('should render thread header', () => { + const discussion = { ...discussionMock }; + discussion.diff_file = mockDiffFile; + discussion.diff_discussion = true; + discussion.expanded = false; + + wrapper.setProps({ discussion }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find('.discussion-header').exists()).toBe(true); + }); + }); + + describe('actions', () => { + it('should toggle reply form', () => { + const replyPlaceholder = wrapper.find(ReplyPlaceholder); + + return wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.vm.isReplying).toEqual(false); + + replyPlaceholder.vm.$emit('onClick'); + }) + .then(() => wrapper.vm.$nextTick()) + .then(() => { + expect(wrapper.vm.isReplying).toEqual(true); + + 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`); + }); + }); + + it('does not render jump to thread button', () => { + expect(wrapper.find('*[data-original-title="Jump to next unresolved thread"]').exists()).toBe( + false, + ); + }); + }); + + describe('for resolved thread', () => { + beforeEach(() => { + const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0]; + wrapper.setProps({ discussion }); + }); + + it('does not display a button to resolve with issue', () => { + const button = wrapper.find(ResolveWithIssueButton); + + expect(button.exists()).toBe(false); + }); + }); + + describe('for unresolved thread', () => { + beforeEach(() => { + const discussion = { + ...getJSONFixture(discussionWithTwoUnresolvedNotes)[0], + expanded: true, + }; + discussion.notes = discussion.notes.map(note => ({ + ...note, + resolved: false, + current_user: { + ...note.current_user, + can_resolve: true, + }, + })); + + wrapper.setProps({ discussion }); + + return wrapper.vm.$nextTick(); + }); + + it('displays a button to resolve with issue', () => { + const button = wrapper.find(ResolveWithIssueButton); + + expect(button.exists()).toBe(true); + }); + }); + + describe('signout widget', () => { + beforeEach(() => { + originalGon = Object.assign({}, window.gon); + window.gon = window.gon || {}; + }); + + afterEach(() => { + wrapper.destroy(); + window.gon = originalGon; + }); + + describe('user is logged in', () => { + beforeEach(() => { + window.gon.current_user_id = userDataMock.id; + store.dispatch('setUserData', userDataMock); + + wrapper = mount(localVue.extend(noteableDiscussion), { + store, + propsData: { discussion: discussionMock }, + localVue, + }); + }); + + it('should not render signed out widget', () => { + expect(Boolean(wrapper.vm.isLoggedIn)).toBe(true); + expect(trimText(wrapper.text())).not.toContain('Please register or sign in to reply'); + }); + }); + + describe('user is not logged in', () => { + beforeEach(() => { + window.gon.current_user_id = null; + store.dispatch('setNoteableData', loggedOutnoteableData); + store.dispatch('setNotesData', notesDataMock); + + wrapper = mount(localVue.extend(noteableDiscussion), { + store, + propsData: { discussion: discussionMock }, + localVue, + }); + }); + + it('should render signed out widget', () => { + expect(Boolean(wrapper.vm.isLoggedIn)).toBe(false); + expect(trimText(wrapper.text())).toContain('Please register or sign in to reply'); + }); + }); + }); +}); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js new file mode 100644 index 00000000000..0d67b1d87a9 --- /dev/null +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -0,0 +1,137 @@ +import { escape } from 'lodash'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import createStore from '~/notes/stores'; +import issueNote from '~/notes/components/noteable_note.vue'; +import NoteHeader from '~/notes/components/note_header.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import NoteActions from '~/notes/components/note_actions.vue'; +import NoteBody from '~/notes/components/note_body.vue'; +import { noteableDataMock, notesDataMock, note } from '../mock_data'; + +describe('issue_note', () => { + let store; + let wrapper; + + beforeEach(() => { + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + + const localVue = createLocalVue(); + wrapper = shallowMount(localVue.extend(issueNote), { + store, + propsData: { + note, + }, + localVue, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render user information', () => { + const { author } = note; + const avatar = wrapper.find(UserAvatarLink); + const avatarProps = avatar.props(); + + expect(avatarProps.linkHref).toBe(author.path); + expect(avatarProps.imgSrc).toBe(author.avatar_url); + expect(avatarProps.imgAlt).toBe(author.name); + expect(avatarProps.imgSize).toBe(40); + }); + + it('should render note header content', () => { + const noteHeader = wrapper.find(NoteHeader); + const noteHeaderProps = noteHeader.props(); + + expect(noteHeaderProps.author).toEqual(note.author); + expect(noteHeaderProps.createdAt).toEqual(note.created_at); + expect(noteHeaderProps.noteId).toEqual(note.id); + }); + + it('should render note actions', () => { + const { author } = note; + const noteActions = wrapper.find(NoteActions); + const noteActionsProps = noteActions.props(); + + expect(noteActionsProps.authorId).toBe(author.id); + expect(noteActionsProps.noteId).toBe(note.id); + expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url); + expect(noteActionsProps.accessLevel).toBe(note.human_access); + expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit); + expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji); + expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit); + expect(noteActionsProps.canReportAsAbuse).toBe(true); + expect(noteActionsProps.canResolve).toBe(false); + expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path); + expect(noteActionsProps.resolvable).toBe(false); + expect(noteActionsProps.isResolved).toBe(false); + expect(noteActionsProps.isResolving).toBe(false); + expect(noteActionsProps.resolvedBy).toEqual({}); + }); + + it('should render issue body', () => { + const noteBody = wrapper.find(NoteBody); + const noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note).toEqual(note); + expect(noteBodyProps.line).toBe(null); + expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit); + expect(noteBodyProps.isEditing).toBe(false); + expect(noteBodyProps.helpPagePath).toBe(''); + }); + + it('prevents note preview xss', done => { + const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + const noteBody = `<img src="${imgSrc}" onload="alert(1)" />`; + const alertSpy = jest.spyOn(window, 'alert'); + store.hotUpdate({ + actions: { + updateNote() {}, + }, + }); + const noteBodyComponent = wrapper.find(NoteBody); + + noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); + + setImmediate(() => { + expect(alertSpy).not.toHaveBeenCalled(); + expect(wrapper.vm.note.note_html).toEqual(escape(noteBody)); + done(); + }); + }); + + describe('cancel edit', () => { + it('restores content of updated note', done => { + const updatedText = 'updated note text'; + store.hotUpdate({ + actions: { + updateNote() {}, + }, + }); + const noteBody = wrapper.find(NoteBody); + noteBody.vm.resetAutoSave = () => {}; + + noteBody.vm.$emit('handleFormUpdate', updatedText, null, () => {}); + + wrapper.vm + .$nextTick() + .then(() => { + const noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note.note_html).toBe(updatedText); + noteBody.vm.$emit('cancelForm'); + }) + .then(() => wrapper.vm.$nextTick()) + .then(() => { + const noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note.note_html).toBe(note.note_html); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/notes/components/toggle_replies_widget_spec.js b/spec/frontend/notes/components/toggle_replies_widget_spec.js new file mode 100644 index 00000000000..b4f68b039cf --- /dev/null +++ b/spec/frontend/notes/components/toggle_replies_widget_spec.js @@ -0,0 +1,78 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import toggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; +import { note } from '../mock_data'; + +const deepCloneObject = obj => JSON.parse(JSON.stringify(obj)); + +describe('toggle replies widget for notes', () => { + let vm; + let ToggleRepliesWidget; + const noteFromOtherUser = deepCloneObject(note); + noteFromOtherUser.author.username = 'fatihacet'; + + const noteFromAnotherUser = deepCloneObject(note); + noteFromAnotherUser.author.username = 'mgreiling'; + noteFromAnotherUser.author.name = 'Mike Greiling'; + + const replies = [note, note, note, noteFromOtherUser, noteFromAnotherUser]; + + beforeEach(() => { + ToggleRepliesWidget = Vue.extend(toggleRepliesWidget); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('collapsed state', () => { + beforeEach(() => { + vm = mountComponent(ToggleRepliesWidget, { + replies, + collapsed: true, + }); + }); + + it('should render the collapsed', () => { + const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' '); + + expect(vm.$el.classList.contains('collapsed')).toEqual(true); + expect(vm.$el.querySelectorAll('.user-avatar-link').length).toEqual(3); + expect(vm.$el.querySelector('time')).not.toBeNull(); + expect(vmTextContent).toContain('5 replies'); + expect(vmTextContent).toContain(`Last reply by ${noteFromAnotherUser.author.name}`); + }); + + it('should emit toggle event when the replies text clicked', () => { + const spy = jest.spyOn(vm, '$emit'); + + vm.$el.querySelector('.js-replies-text').click(); + + expect(spy).toHaveBeenCalledWith('toggle'); + }); + }); + + describe('expanded state', () => { + beforeEach(() => { + vm = mountComponent(ToggleRepliesWidget, { + replies, + collapsed: false, + }); + }); + + it('should render expanded state', () => { + const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' '); + + expect(vm.$el.querySelector('.collapse-replies-btn')).not.toBeNull(); + expect(vmTextContent).toContain('Collapse replies'); + }); + + it('should emit toggle event when the collapse replies text called', () => { + const spy = jest.spyOn(vm, '$emit'); + + vm.$el.querySelector('.js-collapse-replies').click(); + + expect(spy).toHaveBeenCalledWith('toggle'); + }); + }); +}); diff --git a/spec/frontend/notes/stores/collapse_utils_spec.js b/spec/frontend/notes/stores/collapse_utils_spec.js new file mode 100644 index 00000000000..d3019f4b9a4 --- /dev/null +++ b/spec/frontend/notes/stores/collapse_utils_spec.js @@ -0,0 +1,37 @@ +import { + isDescriptionSystemNote, + getTimeDifferenceMinutes, + collapseSystemNotes, +} from '~/notes/stores/collapse_utils'; +import { notesWithDescriptionChanges, collapsedSystemNotes } from '../mock_data'; + +describe('Collapse utils', () => { + const mockSystemNote = { + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + system: true, + created_at: '2018-05-14T21:28:00.000Z', + }; + + it('checks if a system note is of a description type', () => { + expect(isDescriptionSystemNote(mockSystemNote)).toEqual(true); + }); + + it('returns false when a system note is not a description type', () => { + expect(isDescriptionSystemNote(Object.assign({}, mockSystemNote, { note: 'foo' }))).toEqual( + false, + ); + }); + + it('gets the time difference between two notes', () => { + const anotherSystemNote = { + created_at: '2018-05-14T21:33:00.000Z', + }; + + expect(getTimeDifferenceMinutes(mockSystemNote, anotherSystemNote)).toEqual(5); + }); + + it('collapses all description system notes made within 10 minutes or less from each other', () => { + expect(collapseSystemNotes(notesWithDescriptionChanges)).toEqual(collapsedSystemNotes); + }); +}); |