summaryrefslogtreecommitdiff
path: root/spec/frontend
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-04-02 18:08:11 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-04-02 18:08:11 +0000
commit8a7efa45c38ed3200d173d2c3207a8154e583c16 (patch)
tree1bb4d579b95c79aae4946a06fefa089e5549b722 /spec/frontend
parent53b1f4eaa2a451aaba908a5fee7ce97a930021ac (diff)
downloadgitlab-ce-8a7efa45c38ed3200d173d2c3207a8154e583c16.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/__mocks__/sortablejs/index.js2
-rw-r--r--spec/frontend/boards/components/board_column_spec.js172
-rw-r--r--spec/frontend/boards/list_spec.js5
-rw-r--r--spec/frontend/boards/mock_data.js4
-rw-r--r--spec/frontend/lib/utils/file_upload_spec.js14
-rw-r--r--spec/frontend/notes/components/diff_discussion_header_spec.js2
-rw-r--r--spec/frontend/notes/components/diff_with_note_spec.js86
-rw-r--r--spec/frontend/notes/components/discussion_filter_spec.js219
-rw-r--r--spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js30
-rw-r--r--spec/frontend/notes/components/note_actions/reply_button_spec.js29
-rw-r--r--spec/frontend/notes/components/note_actions_spec.js160
-rw-r--r--spec/frontend/notes/components/note_awards_list_spec.js163
-rw-r--r--spec/frontend/notes/components/note_body_spec.js57
-rw-r--r--spec/frontend/notes/components/note_form_spec.js248
-rw-r--r--spec/frontend/notes/components/note_signed_out_widget_spec.js41
-rw-r--r--spec/frontend/notes/components/noteable_discussion_spec.js187
-rw-r--r--spec/frontend/notes/components/noteable_note_spec.js137
-rw-r--r--spec/frontend/notes/components/toggle_replies_widget_spec.js78
-rw-r--r--spec/frontend/notes/stores/collapse_utils_spec.js37
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, '&amp;').replace(/"/g, '&quot;');
+
+ 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);
+ });
+});