From 9f46488805e86b1bc341ea1620b866016c2ce5ed Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 20 May 2020 14:34:42 +0000 Subject: Add latest changes from gitlab-org/gitlab@13-0-stable-ee --- .../__snapshots__/design_note_pin_spec.js.snap | 42 ++ .../__snapshots__/design_presentation_spec.js.snap | 104 ++++ .../__snapshots__/design_scaler_spec.js.snap | 115 +++++ .../components/__snapshots__/image_spec.js.snap | 68 +++ .../components/delete_button_spec.js | 51 ++ .../components/design_note_pin_spec.js | 49 ++ .../__snapshots__/design_note_spec.js.snap | 61 +++ .../__snapshots__/design_reply_form_spec.js.snap | 15 + .../design_notes/design_discussion_spec.js | 133 +++++ .../components/design_notes/design_note_spec.js | 170 +++++++ .../design_notes/design_reply_form_spec.js | 182 +++++++ .../components/design_overlay_spec.js | 393 +++++++++++++++ .../components/design_presentation_spec.js | 546 +++++++++++++++++++++ .../components/design_scaler_spec.js | 67 +++ .../design_management/components/image_spec.js | 133 +++++ .../list/__snapshots__/item_spec.js.snap | 472 ++++++++++++++++++ .../design_management/components/list/item_spec.js | 168 +++++++ .../toolbar/__snapshots__/index_spec.js.snap | 61 +++ .../__snapshots__/pagination_button_spec.js.snap | 28 ++ .../toolbar/__snapshots__/pagination_spec.js.snap | 29 ++ .../components/toolbar/index_spec.js | 123 +++++ .../components/toolbar/pagination_button_spec.js | 61 +++ .../components/toolbar/pagination_spec.js | 79 +++ .../upload/__snapshots__/button_spec.js.snap | 79 +++ .../__snapshots__/design_dropzone_spec.js.snap | 455 +++++++++++++++++ .../design_version_dropdown_spec.js.snap | 111 +++++ .../components/upload/button_spec.js | 59 +++ .../components/upload/design_dropzone_spec.js | 132 +++++ .../upload/design_version_dropdown_spec.js | 114 +++++ .../components/upload/mock_data/all_versions.js | 14 + 30 files changed, 4114 insertions(+) create mode 100644 spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap create mode 100644 spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap create mode 100644 spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap create mode 100644 spec/frontend/design_management/components/__snapshots__/image_spec.js.snap create mode 100644 spec/frontend/design_management/components/delete_button_spec.js create mode 100644 spec/frontend/design_management/components/design_note_pin_spec.js create mode 100644 spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap create mode 100644 spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap create mode 100644 spec/frontend/design_management/components/design_notes/design_discussion_spec.js create mode 100644 spec/frontend/design_management/components/design_notes/design_note_spec.js create mode 100644 spec/frontend/design_management/components/design_notes/design_reply_form_spec.js create mode 100644 spec/frontend/design_management/components/design_overlay_spec.js create mode 100644 spec/frontend/design_management/components/design_presentation_spec.js create mode 100644 spec/frontend/design_management/components/design_scaler_spec.js create mode 100644 spec/frontend/design_management/components/image_spec.js create mode 100644 spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap create mode 100644 spec/frontend/design_management/components/list/item_spec.js create mode 100644 spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap create mode 100644 spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap create mode 100644 spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap create mode 100644 spec/frontend/design_management/components/toolbar/index_spec.js create mode 100644 spec/frontend/design_management/components/toolbar/pagination_button_spec.js create mode 100644 spec/frontend/design_management/components/toolbar/pagination_spec.js create mode 100644 spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap create mode 100644 spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap create mode 100644 spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap create mode 100644 spec/frontend/design_management/components/upload/button_spec.js create mode 100644 spec/frontend/design_management/components/upload/design_dropzone_spec.js create mode 100644 spec/frontend/design_management/components/upload/design_version_dropdown_spec.js create mode 100644 spec/frontend/design_management/components/upload/mock_data/all_versions.js (limited to 'spec/frontend/design_management/components') diff --git a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap new file mode 100644 index 00000000000..4828e8cb3c2 --- /dev/null +++ b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design discussions component should match the snapshot of note when repositioning 1`] = ` + +`; + +exports[`Design discussions component should match the snapshot of note with index 1`] = ` + +`; + +exports[`Design discussions component should match the snapshot of note without index 1`] = ` + +`; diff --git a/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap new file mode 100644 index 00000000000..189962c5b2e --- /dev/null +++ b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap @@ -0,0 +1,104 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management design presentation component currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = ` +
+
+ + + +
+
+`; + +exports[`Design management design presentation component currentCommentForm is null when isAnnotating is false 1`] = ` +
+
+ + + +
+
+`; + +exports[`Design management design presentation component currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = ` +
+
+ + + +
+
+`; + +exports[`Design management design presentation component renders empty state when no image provided 1`] = ` +
+
+ + + +
+
+`; + +exports[`Design management design presentation component renders image and overlay when image provided 1`] = ` +
+
+ + + +
+
+`; diff --git a/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap new file mode 100644 index 00000000000..cb4575cbd11 --- /dev/null +++ b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap @@ -0,0 +1,115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management design scaler component minus and reset buttons are disabled when scale === 1 1`] = ` +
+ + + + + +
+`; + +exports[`Design management design scaler component minus and reset buttons are enabled when scale > 1 1`] = ` +
+ + + + + +
+`; + +exports[`Design management design scaler component plus button is disabled when scale === 2 1`] = ` +
+ + + + + +
+`; diff --git a/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap new file mode 100644 index 00000000000..acaa62b11eb --- /dev/null +++ b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management large image component renders image 1`] = ` +
+ + + test +
+`; + +exports[`Design management large image component renders loading state 1`] = ` +
+ + + +
+`; + +exports[`Design management large image component renders media broken icon on error 1`] = ` + +`; + +exports[`Design management large image component sets correct classes and styles if imageStyle is set 1`] = ` +
+ + + test +
+`; + +exports[`Design management large image component zoom sets image style when zoomed 1`] = ` +
+ + + test +
+`; diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js new file mode 100644 index 00000000000..9d3bcd98e44 --- /dev/null +++ b/spec/frontend/design_management/components/delete_button_spec.js @@ -0,0 +1,51 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import BatchDeleteButton from '~/design_management/components/delete_button.vue'; + +describe('Batch delete button component', () => { + let wrapper; + + const findButton = () => wrapper.find(GlDeprecatedButton); + const findModal = () => wrapper.find(GlModal); + + function createComponent(isDeleting = false) { + wrapper = shallowMount(BatchDeleteButton, { + propsData: { + isDeleting, + }, + directives: { + GlModalDirective, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders non-disabled button by default', () => { + createComponent(); + + expect(findButton().exists()).toBe(true); + expect(findButton().attributes('disabled')).toBeFalsy(); + }); + + it('renders disabled button when design is deleting', () => { + createComponent(true); + expect(findButton().attributes('disabled')).toBeTruthy(); + }); + + it('emits `deleteSelectedDesigns` event on modal ok click', () => { + createComponent(); + findButton().vm.$emit('click'); + return wrapper.vm + .$nextTick() + .then(() => { + findModal().vm.$emit('ok'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/design_management/components/design_note_pin_spec.js b/spec/frontend/design_management/components/design_note_pin_spec.js new file mode 100644 index 00000000000..4f7260b1363 --- /dev/null +++ b/spec/frontend/design_management/components/design_note_pin_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignNotePin from '~/design_management/components/design_note_pin.vue'; + +describe('Design discussions component', () => { + let wrapper; + + function createComponent(propsData = {}) { + wrapper = shallowMount(DesignNotePin, { + propsData: { + position: { + left: '10px', + top: '10px', + }, + ...propsData, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('should match the snapshot of note without index', () => { + createComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should match the snapshot of note with index', () => { + createComponent({ label: '1' }); + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should match the snapshot of note when repositioning', () => { + createComponent({ repositioning: true }); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('pinStyle', () => { + it('sets cursor to `move` when repositioning = true', () => { + createComponent({ repositioning: true }); + expect(wrapper.vm.pinStyle.cursor).toBe('move'); + }); + + it('does not set cursor when repositioning = false', () => { + createComponent(); + expect(wrapper.vm.pinStyle.cursor).toBe(undefined); + }); + }); +}); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap new file mode 100644 index 00000000000..e071274cc81 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design note component should match the snapshot 1`] = ` + + + +
+
+ + + + + + + + + @ + + + + + + + + +
+ + +
+ +
+ +`; diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap new file mode 100644 index 00000000000..e01c79e3520 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = ` +"" +`; + +exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = ` +"" +`; diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js new file mode 100644 index 00000000000..b16b26ff82f --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -0,0 +1,133 @@ +import { shallowMount } from '@vue/test-utils'; +import { ApolloMutation } from 'vue-apollo'; +import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; +import DesignNote from '~/design_management/components/design_notes/design_note.vue'; +import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; +import createNoteMutation from '~/design_management/graphql/mutations/createNote.mutation.graphql'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; + +describe('Design discussions component', () => { + let wrapper; + + const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder); + const findReplyForm = () => wrapper.find(DesignReplyForm); + + const mutationVariables = { + mutation: createNoteMutation, + update: expect.anything(), + variables: { + input: { + noteableId: 'noteable-id', + body: 'test', + discussionId: '0', + }, + }, + }; + const mutate = jest.fn(() => Promise.resolve()); + const $apollo = { + mutate, + }; + + function createComponent(props = {}, data = {}) { + wrapper = shallowMount(DesignDiscussion, { + propsData: { + discussion: { + id: '0', + notes: [ + { + id: '1', + }, + { + id: '2', + }, + ], + }, + noteableId: 'noteable-id', + designId: 'design-id', + discussionIndex: 1, + ...props, + }, + data() { + return { + ...data, + }; + }, + stubs: { + ReplyPlaceholder, + ApolloMutation, + }, + mocks: { $apollo }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders correct amount of discussion notes', () => { + createComponent(); + expect(wrapper.findAll(DesignNote)).toHaveLength(2); + }); + + it('renders reply placeholder by default', () => { + createComponent(); + expect(findReplyPlaceholder().exists()).toBe(true); + }); + + it('hides reply placeholder and opens form on placeholder click', () => { + createComponent(); + findReplyPlaceholder().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findReplyPlaceholder().exists()).toBe(false); + expect(findReplyForm().exists()).toBe(true); + }); + }); + + it('calls mutation on submitting form and closes the form', () => { + createComponent({}, { discussionComment: 'test', isFormRendered: true }); + + findReplyForm().vm.$emit('submitForm'); + expect(mutate).toHaveBeenCalledWith(mutationVariables); + + return mutate() + .then(() => { + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findReplyForm().exists()).toBe(false); + }); + }); + + it('clears the discussion comment on closing comment form', () => { + createComponent({}, { discussionComment: 'test', isFormRendered: true }); + + return wrapper.vm + .$nextTick() + .then(() => { + findReplyForm().vm.$emit('cancelForm'); + + expect(wrapper.vm.discussionComment).toBe(''); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findReplyForm().exists()).toBe(false); + }); + }); + + it('applies correct class to design notes when discussion is highlighted', () => { + createComponent( + {}, + { + activeDiscussion: { + id: '1', + source: 'pin', + }, + }, + ); + + expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe( + true, + ); + }); +}); diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js new file mode 100644 index 00000000000..8b32d3022ee --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -0,0 +1,170 @@ +import { shallowMount } from '@vue/test-utils'; +import { ApolloMutation } from 'vue-apollo'; +import DesignNote from '~/design_management/components/design_notes/design_note.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; + +const scrollIntoViewMock = jest.fn(); +const note = { + id: 'gid://gitlab/DiffNote/123', + author: { + id: 'author-id', + }, + body: 'test', + userPermissions: { + adminNote: false, + }, +}; +HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; + +const $route = { + hash: '#note_123', +}; + +const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } }); + +describe('Design note component', () => { + let wrapper; + + const findUserAvatar = () => wrapper.find(UserAvatarLink); + const findUserLink = () => wrapper.find('.js-user-link'); + const findReplyForm = () => wrapper.find(DesignReplyForm); + const findEditButton = () => wrapper.find('.js-note-edit'); + const findNoteContent = () => wrapper.find('.js-note-text'); + + function createComponent(props = {}, data = { isEditing: false }) { + wrapper = shallowMount(DesignNote, { + propsData: { + note: {}, + ...props, + }, + data() { + return { + ...data, + }; + }, + mocks: { + $route, + $apollo: { + mutate, + }, + }, + stubs: { + ApolloMutation, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('should match the snapshot', () => { + createComponent({ + note, + }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should render an author', () => { + createComponent({ + note, + }); + + expect(findUserAvatar().exists()).toBe(true); + expect(findUserLink().exists()).toBe(true); + }); + + it('should render a time ago tooltip if note has createdAt property', () => { + createComponent({ + note: { + ...note, + createdAt: '2019-07-26T15:02:20Z', + }, + }); + + expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); + }); + + it('should trigger a scrollIntoView method', () => { + createComponent({ + note, + }); + + expect(scrollIntoViewMock).toHaveBeenCalled(); + }); + + it('should not render edit icon when user does not have a permission', () => { + createComponent({ + note, + }); + + expect(findEditButton().exists()).toBe(false); + }); + + describe('when user has a permission to edit note', () => { + it('should open an edit form on edit button click', () => { + createComponent({ + note: { + ...note, + userPermissions: { + adminNote: true, + }, + }, + }); + + findEditButton().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findReplyForm().exists()).toBe(true); + expect(findNoteContent().exists()).toBe(false); + }); + }); + + describe('when edit form is rendered', () => { + beforeEach(() => { + createComponent( + { + note: { + ...note, + userPermissions: { + adminNote: true, + }, + }, + }, + { isEditing: true }, + ); + }); + + it('should not render note content and should render reply form', () => { + expect(findNoteContent().exists()).toBe(false); + expect(findReplyForm().exists()).toBe(true); + }); + + it('hides the form on hideForm event', () => { + findReplyForm().vm.$emit('cancelForm'); + + return wrapper.vm.$nextTick().then(() => { + expect(findReplyForm().exists()).toBe(false); + expect(findNoteContent().exists()).toBe(true); + }); + }); + + it('calls a mutation on submitForm event and hides a form', () => { + findReplyForm().vm.$emit('submitForm'); + expect(mutate).toHaveBeenCalled(); + + return mutate() + .then(() => { + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findReplyForm().exists()).toBe(false); + expect(findNoteContent().exists()).toBe(true); + }); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js new file mode 100644 index 00000000000..34b8f1f9fa8 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js @@ -0,0 +1,182 @@ +import { mount } from '@vue/test-utils'; +import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; + +const showModal = jest.fn(); + +const GlModal = { + template: '
', + methods: { + show: showModal, + }, +}; + +describe('Design reply form component', () => { + let wrapper; + + const findTextarea = () => wrapper.find('textarea'); + const findSubmitButton = () => wrapper.find({ ref: 'submitButton' }); + const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); + const findModal = () => wrapper.find({ ref: 'cancelCommentModal' }); + + function createComponent(props = {}) { + wrapper = mount(DesignReplyForm, { + propsData: { + value: '', + isSaving: false, + ...props, + }, + stubs: { GlModal }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('textarea has focus after component mount', () => { + createComponent(); + + expect(findTextarea().element).toEqual(document.activeElement); + }); + + it('renders button text as "Comment" when creating a comment', () => { + createComponent(); + + expect(findSubmitButton().html()).toMatchSnapshot(); + }); + + it('renders button text as "Save comment" when creating a comment', () => { + createComponent({ isNewComment: false }); + + expect(findSubmitButton().html()).toMatchSnapshot(); + }); + + describe('when form has no text', () => { + beforeEach(() => { + createComponent({ + value: '', + }); + }); + + it('submit button is disabled', () => { + expect(findSubmitButton().attributes().disabled).toBeTruthy(); + }); + + it('does not emit submitForm event on textarea ctrl+enter keydown', () => { + findTextarea().trigger('keydown.enter', { + ctrlKey: true, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('submitForm')).toBeFalsy(); + }); + }); + + it('does not emit submitForm event on textarea meta+enter keydown', () => { + findTextarea().trigger('keydown.enter', { + metaKey: true, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('submitForm')).toBeFalsy(); + }); + }); + + it('emits cancelForm event on pressing escape button on textarea', () => { + findTextarea().trigger('keyup.esc'); + + expect(wrapper.emitted('cancelForm')).toBeTruthy(); + }); + + it('emits cancelForm event on clicking Cancel button', () => { + findCancelButton().vm.$emit('click'); + + expect(wrapper.emitted('cancelForm')).toHaveLength(1); + }); + }); + + describe('when form has text', () => { + beforeEach(() => { + createComponent({ + value: 'test', + }); + }); + + it('submit button is enabled', () => { + expect(findSubmitButton().attributes().disabled).toBeFalsy(); + }); + + it('emits submitForm event on Comment button click', () => { + findSubmitButton().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('submitForm')).toBeTruthy(); + }); + }); + + it('emits submitForm event on textarea ctrl+enter keydown', () => { + findTextarea().trigger('keydown.enter', { + ctrlKey: true, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('submitForm')).toBeTruthy(); + }); + }); + + it('emits submitForm event on textarea meta+enter keydown', () => { + findTextarea().trigger('keydown.enter', { + metaKey: true, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('submitForm')).toBeTruthy(); + }); + }); + + it('emits input event on changing textarea content', () => { + findTextarea().setValue('test2'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('input')).toBeTruthy(); + }); + }); + + it('emits cancelForm event on Escape key if text was not changed', () => { + findTextarea().trigger('keyup.esc'); + + expect(wrapper.emitted('cancelForm')).toBeTruthy(); + }); + + it('opens confirmation modal on Escape key when text has changed', () => { + wrapper.setProps({ value: 'test2' }); + + return wrapper.vm.$nextTick().then(() => { + findTextarea().trigger('keyup.esc'); + expect(showModal).toHaveBeenCalled(); + }); + }); + + it('emits cancelForm event on Cancel button click if text was not changed', () => { + findCancelButton().trigger('click'); + + expect(wrapper.emitted('cancelForm')).toBeTruthy(); + }); + + it('opens confirmation modal on Cancel button click when text has changed', () => { + wrapper.setProps({ value: 'test2' }); + + return wrapper.vm.$nextTick().then(() => { + findCancelButton().trigger('click'); + expect(showModal).toHaveBeenCalled(); + }); + }); + + it('emits cancelForm event on modal Ok button click', () => { + findTextarea().trigger('keyup.esc'); + findModal().vm.$emit('ok'); + + expect(wrapper.emitted('cancelForm')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js new file mode 100644 index 00000000000..1c9b130aca6 --- /dev/null +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -0,0 +1,393 @@ +import { mount } from '@vue/test-utils'; +import DesignOverlay from '~/design_management/components/design_overlay.vue'; +import updateActiveDiscussion from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql'; +import notes from '../mock_data/notes'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management/constants'; + +const mutate = jest.fn(() => Promise.resolve()); + +describe('Design overlay component', () => { + let wrapper; + + const mockDimensions = { width: 100, height: 100 }; + const mockNoteNotAuthorised = { + id: 'note-not-authorised', + discussion: { id: 'discussion-not-authorised' }, + position: { + x: 1, + y: 80, + ...mockDimensions, + }, + userPermissions: {}, + }; + + const findOverlay = () => wrapper.find('.image-diff-overlay'); + const findAllNotes = () => wrapper.findAll('.js-image-badge'); + const findCommentBadge = () => wrapper.find('.comment-indicator'); + const findFirstBadge = () => findAllNotes().at(0); + const findSecondBadge = () => findAllNotes().at(1); + + const clickAndDragBadge = (elem, fromPoint, toPoint) => { + elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y }); + return wrapper.vm.$nextTick().then(() => { + elem.trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y }); + return wrapper.vm.$nextTick(); + }); + }; + + function createComponent(props = {}, data = {}) { + wrapper = mount(DesignOverlay, { + propsData: { + dimensions: mockDimensions, + position: { + top: '0', + left: '0', + }, + ...props, + }, + data() { + return { + activeDiscussion: { + id: null, + source: null, + }, + ...data, + }; + }, + mocks: { + $apollo: { + mutate, + }, + }, + }); + } + + it('should have correct inline style', () => { + createComponent(); + + expect(wrapper.find('.image-diff-overlay').attributes().style).toBe( + 'width: 100px; height: 100px; top: 0px; left: 0px;', + ); + }); + + it('should emit `openCommentForm` when clicking on overlay', () => { + createComponent(); + const newCoordinates = { + x: 10, + y: 10, + }; + + wrapper + .find('.image-diff-overlay-add-comment') + .trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('openCommentForm')).toEqual([ + [{ x: newCoordinates.x, y: newCoordinates.y }], + ]); + }); + }); + + describe('with notes', () => { + beforeEach(() => { + createComponent({ + notes, + }); + }); + + it('should render a correct amount of notes', () => { + expect(findAllNotes()).toHaveLength(notes.length); + }); + + it('should have a correct style for each note badge', () => { + expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;'); + expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;'); + }); + + it('should recalculate badges positions on window resize', () => { + createComponent({ + notes, + dimensions: { + width: 400, + height: 400, + }, + }); + + expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;'); + + wrapper.setProps({ + dimensions: { + width: 200, + height: 200, + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;'); + }); + }); + + it('should call an update active discussion mutation when clicking a note without moving it', () => { + const note = notes[0]; + const { position } = note; + const mutationVariables = { + mutation: updateActiveDiscussion, + variables: { + id: note.id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin, + }, + }; + + findFirstBadge().trigger('mousedown', { clientX: position.x, clientY: position.y }); + + return wrapper.vm.$nextTick().then(() => { + findFirstBadge().trigger('mouseup', { clientX: position.x, clientY: position.y }); + expect(mutate).toHaveBeenCalledWith(mutationVariables); + }); + }); + + it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => { + wrapper.setData({ + activeDiscussion: { + id: notes[0].id, + source: 'discussion', + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(findSecondBadge().classes()).toContain('inactive'); + }); + }); + }); + + describe('when moving notes', () => { + it('should update badge style when note is being moved', () => { + createComponent({ + notes, + }); + + const { position } = notes[0]; + + return clickAndDragBadge( + findFirstBadge(), + { x: position.x, y: position.y }, + { x: 20, y: 20 }, + ).then(() => { + expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 20px; cursor: move;'); + }); + }); + + it('should emit `moveNote` event when note-moving action ends', () => { + createComponent({ notes }); + const note = notes[0]; + const { position } = note; + const newCoordinates = { x: 20, y: 20 }; + + wrapper.setData({ + movingNoteNewPosition: { + ...position, + ...newCoordinates, + }, + movingNoteStartPosition: { + noteId: notes[0].id, + discussionId: notes[0].discussion.id, + ...position, + }, + }); + + const badge = findFirstBadge(); + return clickAndDragBadge(badge, { x: position.x, y: position.y }, newCoordinates) + .then(() => { + badge.trigger('mouseup'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.emitted('moveNote')).toEqual([ + [ + { + noteId: notes[0].id, + discussionId: notes[0].discussion.id, + coordinates: newCoordinates, + }, + ], + ]); + }); + }); + + it('should do nothing if [adminNote] permission is not present', () => { + createComponent({ + dimensions: mockDimensions, + notes: [mockNoteNotAuthorised], + }); + + const badge = findAllNotes().at(0); + return clickAndDragBadge( + badge, + { x: mockNoteNotAuthorised.x, y: mockNoteNotAuthorised.y }, + { x: 20, y: 20 }, + ).then(() => { + expect(wrapper.vm.movingNoteStartPosition).toBeNull(); + expect(findFirstBadge().attributes().style).toBe('left: 1px; top: 80px;'); + }); + }); + }); + + describe('with a new form', () => { + it('should render a new comment badge', () => { + createComponent({ + currentCommentForm: { + ...notes[0].position, + }, + }); + + expect(findCommentBadge().exists()).toBe(true); + expect(findCommentBadge().attributes().style).toBe('left: 10px; top: 15px;'); + }); + + describe('when moving the comment badge', () => { + it('should update badge style to reflect new position', () => { + const { position } = notes[0]; + + createComponent({ + currentCommentForm: { + ...position, + }, + }); + + return clickAndDragBadge( + findCommentBadge(), + { x: position.x, y: position.y }, + { x: 20, y: 20 }, + ).then(() => { + expect(findCommentBadge().attributes().style).toBe( + 'left: 20px; top: 20px; cursor: move;', + ); + }); + }); + + it('should update badge style when note-moving action ends', () => { + const { position } = notes[0]; + createComponent({ + currentCommentForm: { + ...position, + }, + }); + + const commentBadge = findCommentBadge(); + const toPoint = { x: 20, y: 20 }; + + return clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint) + .then(() => { + commentBadge.trigger('mouseup'); + // simulates the currentCommentForm being updated in index.vue component, and + // propagated back down to this prop + wrapper.setProps({ + currentCommentForm: { height: position.height, width: position.width, ...toPoint }, + }); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(commentBadge.attributes().style).toBe('left: 20px; top: 20px;'); + }); + }); + + it.each` + element | getElementFunc | event + ${'overlay'} | ${findOverlay} | ${'mouseleave'} + ${'comment badge'} | ${findCommentBadge} | ${'mouseup'} + `( + 'should emit `openCommentForm` event when $event fired on $element element', + ({ getElementFunc, event }) => { + createComponent({ + notes, + currentCommentForm: { + ...notes[0].position, + }, + }); + + const newCoordinates = { x: 20, y: 20 }; + wrapper.setData({ + movingNoteStartPosition: { + ...notes[0].position, + }, + movingNoteNewPosition: { + ...notes[0].position, + ...newCoordinates, + }, + }); + + getElementFunc().trigger(event); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]); + }); + }, + ); + }); + }); + + describe('getMovingNotePositionDelta', () => { + it('should calculate delta correctly from state', () => { + createComponent(); + + wrapper.setData({ + movingNoteStartPosition: { + clientX: 10, + clientY: 20, + }, + }); + + const mockMouseEvent = { + clientX: 30, + clientY: 10, + }; + + expect(wrapper.vm.getMovingNotePositionDelta(mockMouseEvent)).toEqual({ + deltaX: 20, + deltaY: -10, + }); + }); + }); + + describe('isPositionInOverlay', () => { + createComponent({ dimensions: mockDimensions }); + + it.each` + test | coordinates | expectedResult + ${'within overlay bounds'} | ${{ x: 50, y: 50 }} | ${true} + ${'outside overlay bounds'} | ${{ x: 101, y: 101 }} | ${false} + `('returns [$expectedResult] when position is $test', ({ coordinates, expectedResult }) => { + const position = { ...mockDimensions, ...coordinates }; + + expect(wrapper.vm.isPositionInOverlay(position)).toBe(expectedResult); + }); + }); + + describe('getNoteRelativePosition', () => { + it('calculates position correctly', () => { + createComponent({ dimensions: mockDimensions }); + const position = { x: 50, y: 50, width: 200, height: 200 }; + + expect(wrapper.vm.getNoteRelativePosition(position)).toEqual({ left: 25, top: 25 }); + }); + }); + + describe('canMoveNote', () => { + it.each` + adminNotePermission | canMoveNoteResult + ${true} | ${true} + ${false} | ${false} + ${undefined} | ${false} + `( + 'returns [$canMoveNoteResult] when [adminNote permission] is [$adminNotePermission]', + ({ adminNotePermission, canMoveNoteResult }) => { + createComponent(); + + const note = { + userPermissions: { + adminNote: adminNotePermission, + }, + }; + expect(wrapper.vm.canMoveNote(note)).toBe(canMoveNoteResult); + }, + ); + }); +}); diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js new file mode 100644 index 00000000000..8a709393d92 --- /dev/null +++ b/spec/frontend/design_management/components/design_presentation_spec.js @@ -0,0 +1,546 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignPresentation from '~/design_management/components/design_presentation.vue'; +import DesignOverlay from '~/design_management/components/design_overlay.vue'; + +const mockOverlayData = { + overlayDimensions: { + width: 100, + height: 100, + }, + overlayPosition: { + top: '0', + left: '0', + }, +}; + +describe('Design management design presentation component', () => { + let wrapper; + + function createComponent( + { image, imageName, discussions = [], isAnnotating = false } = {}, + data = {}, + stubs = {}, + ) { + wrapper = shallowMount(DesignPresentation, { + propsData: { + image, + imageName, + discussions, + isAnnotating, + }, + stubs, + }); + + wrapper.setData(data); + wrapper.element.scrollTo = jest.fn(); + } + + const findOverlayCommentButton = () => wrapper.find('.image-diff-overlay-add-comment'); + + /** + * Spy on $refs and mock given values + * @param {Object} viewportDimensions {width, height} + * @param {Object} childDimensions {width, height} + * @param {Float} scrollTopPerc 0 < x < 1 + * @param {Float} scrollLeftPerc 0 < x < 1 + */ + function mockRefDimensions( + ref, + viewportDimensions, + childDimensions, + scrollTopPerc, + scrollLeftPerc, + ) { + jest.spyOn(ref, 'scrollWidth', 'get').mockReturnValue(childDimensions.width); + jest.spyOn(ref, 'scrollHeight', 'get').mockReturnValue(childDimensions.height); + jest.spyOn(ref, 'offsetWidth', 'get').mockReturnValue(viewportDimensions.width); + jest.spyOn(ref, 'offsetHeight', 'get').mockReturnValue(viewportDimensions.height); + jest + .spyOn(ref, 'scrollLeft', 'get') + .mockReturnValue((childDimensions.width - viewportDimensions.width) * scrollLeftPerc); + jest + .spyOn(ref, 'scrollTop', 'get') + .mockReturnValue((childDimensions.height - viewportDimensions.height) * scrollTopPerc); + } + + function clickDragExplore(startCoords, endCoords, { useTouchEvents, mouseup } = {}) { + const event = useTouchEvents + ? { + mousedown: 'touchstart', + mousemove: 'touchmove', + mouseup: 'touchend', + } + : { + mousedown: 'mousedown', + mousemove: 'mousemove', + mouseup: 'mouseup', + }; + + const addCommentOverlay = findOverlayCommentButton(); + + // triggering mouse events on this element best simulates + // reality, as it is the lowest-level node that needs to + // respond to mouse events + addCommentOverlay.trigger(event.mousedown, { + clientX: startCoords.clientX, + clientY: startCoords.clientY, + }); + return wrapper.vm + .$nextTick() + .then(() => { + addCommentOverlay.trigger(event.mousemove, { + clientX: endCoords.clientX, + clientY: endCoords.clientY, + }); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + if (mouseup) { + addCommentOverlay.trigger(event.mouseup); + return wrapper.vm.$nextTick(); + } + + return undefined; + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders image and overlay when image provided', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders empty state when no image provided', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('openCommentForm event emits correct data', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + + wrapper.vm.openCommentForm({ x: 1, y: 1 }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('openCommentForm')).toEqual([ + [{ ...mockOverlayData.overlayDimensions, x: 1, y: 1 }], + ]); + }); + }); + + describe('currentCommentForm', () => { + it('is null when isAnnotating is false', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.currentCommentForm).toBeNull(); + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('is null when isAnnotating is true but annotation position is falsey', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + isAnnotating: true, + }, + mockOverlayData, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.currentCommentForm).toBeNull(); + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('is equal to current annotation position when isAnnotating is true', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + isAnnotating: true, + }, + { + ...mockOverlayData, + currentAnnotationPosition: { + x: 1, + y: 1, + width: 100, + height: 100, + }, + }, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.currentCommentForm).toEqual({ + x: 1, + y: 1, + width: 100, + height: 100, + }); + expect(wrapper.element).toMatchSnapshot(); + }); + }); + }); + + describe('setOverlayPosition', () => { + beforeEach(() => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets overlay position correctly when overlay is smaller than viewport', () => { + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200); + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200); + + wrapper.vm.setOverlayPosition(); + expect(wrapper.vm.overlayPosition).toEqual({ + left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`, + top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`, + }); + }); + + it('sets overlay position correctly when overlay width is larger than viewports', () => { + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(50); + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200); + + wrapper.vm.setOverlayPosition(); + expect(wrapper.vm.overlayPosition).toEqual({ + left: '0', + top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`, + }); + }); + + it('sets overlay position correctly when overlay height is larger than viewports', () => { + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200); + jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(50); + + wrapper.vm.setOverlayPosition(); + expect(wrapper.vm.overlayPosition).toEqual({ + left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`, + top: '0', + }); + }); + }); + + describe('getViewportCenter', () => { + beforeEach(() => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + }); + + it('calculate center correctly with no scroll', () => { + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 10, height: 10 }, + { width: 20, height: 20 }, + 0, + 0, + ); + + expect(wrapper.vm.getViewportCenter()).toEqual({ + x: 5, + y: 5, + }); + }); + + it('calculate center correctly with some scroll', () => { + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 10, height: 10 }, + { width: 20, height: 20 }, + 0.5, + 0.5, + ); + + expect(wrapper.vm.getViewportCenter()).toEqual({ + x: 10, + y: 10, + }); + }); + + it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => { + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 20, height: 20 }, + { width: 20, height: 20 }, + 0.5, + 0.5, + ); + + expect(wrapper.vm.getViewportCenter()).toEqual({ + x: 10, + y: 10, + }); + }); + }); + + describe('scaleZoomFocalPoint', () => { + it('scales focal point correctly when zooming in', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + { + ...mockOverlayData, + zoomFocalPoint: { + x: 5, + y: 5, + width: 50, + height: 50, + }, + }, + ); + + wrapper.vm.scaleZoomFocalPoint(); + expect(wrapper.vm.zoomFocalPoint).toEqual({ + x: 10, + y: 10, + width: 100, + height: 100, + }); + }); + + it('scales focal point correctly when zooming out', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + { + ...mockOverlayData, + zoomFocalPoint: { + x: 10, + y: 10, + width: 200, + height: 200, + }, + }, + ); + + wrapper.vm.scaleZoomFocalPoint(); + expect(wrapper.vm.zoomFocalPoint).toEqual({ + x: 5, + y: 5, + width: 100, + height: 100, + }); + }); + }); + + describe('onImageResize', () => { + it('sets zoom focal point on initial load', () => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + + wrapper.setMethods({ + shiftZoomFocalPoint: jest.fn(), + scaleZoomFocalPoint: jest.fn(), + scrollToFocalPoint: jest.fn(), + }); + + wrapper.vm.onImageResize({ width: 10, height: 10 }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.shiftZoomFocalPoint).toHaveBeenCalled(); + expect(wrapper.vm.initialLoad).toBe(false); + }); + }); + + it('calls scaleZoomFocalPoint and scrollToFocalPoint after initial load', () => { + wrapper.vm.onImageResize({ width: 10, height: 10 }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.scaleZoomFocalPoint).toHaveBeenCalled(); + expect(wrapper.vm.scrollToFocalPoint).toHaveBeenCalled(); + }); + }); + }); + + describe('onPresentationMousedown', () => { + it.each` + scenario | width | height + ${'width overflows'} | ${101} | ${100} + ${'height overflows'} | ${100} | ${101} + ${'width and height overflows'} | ${200} | ${200} + `('sets lastDragPosition when design $scenario', ({ width, height }) => { + createComponent(); + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 100, height: 100 }, + { width, height }, + ); + + const newLastDragPosition = { x: 2, y: 2 }; + wrapper.vm.onPresentationMousedown({ + clientX: newLastDragPosition.x, + clientY: newLastDragPosition.y, + }); + + expect(wrapper.vm.lastDragPosition).toStrictEqual(newLastDragPosition); + }); + + it('does not set lastDragPosition if design does not overflow', () => { + const lastDragPosition = { x: 1, y: 1 }; + + createComponent({}, { lastDragPosition }); + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 100, height: 100 }, + { width: 50, height: 50 }, + ); + + wrapper.vm.onPresentationMousedown({ clientX: 2, clientY: 2 }); + + // check lastDragPosition is unchanged + expect(wrapper.vm.lastDragPosition).toStrictEqual(lastDragPosition); + }); + }); + + describe('getAnnotationPositon', () => { + it.each` + coordinates | overlayDimensions | position + ${{ x: 100, y: 100 }} | ${{ width: 50, height: 50 }} | ${{ x: 100, y: 100, width: 50, height: 50 }} + ${{ x: 100.2, y: 100.5 }} | ${{ width: 50.6, height: 50.0 }} | ${{ x: 100, y: 101, width: 51, height: 50 }} + `('returns correct annotation position', ({ coordinates, overlayDimensions, position }) => { + createComponent(undefined, { + overlayDimensions: { + width: overlayDimensions.width, + height: overlayDimensions.height, + }, + }); + + expect(wrapper.vm.getAnnotationPositon(coordinates)).toStrictEqual(position); + }); + }); + + describe('when design is overflowing', () => { + beforeEach(() => { + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + { + 'design-overlay': DesignOverlay, + }, + ); + + // mock a design that overflows + mockRefDimensions( + wrapper.vm.$refs.presentationViewport, + { width: 10, height: 10 }, + { width: 20, height: 20 }, + 0, + 0, + ); + }); + + it('opens a comment form if design was not dragged', () => { + const addCommentOverlay = findOverlayCommentButton(); + const startCoords = { + clientX: 1, + clientY: 1, + }; + + addCommentOverlay.trigger('mousedown', { + clientX: startCoords.clientX, + clientY: startCoords.clientY, + }); + + return wrapper.vm + .$nextTick() + .then(() => { + addCommentOverlay.trigger('mouseup'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.emitted('openCommentForm')).toBeDefined(); + }); + }); + + describe('when clicking and dragging', () => { + it.each` + description | useTouchEvents + ${'with touch events'} | ${true} + ${'without touch events'} | ${false} + `('calls scrollTo with correct arguments $description', ({ useTouchEvents }) => { + return clickDragExplore( + { clientX: 0, clientY: 0 }, + { clientX: 10, clientY: 10 }, + { useTouchEvents }, + ).then(() => { + expect(wrapper.element.scrollTo).toHaveBeenCalledTimes(1); + expect(wrapper.element.scrollTo).toHaveBeenCalledWith(-10, -10); + }); + }); + + it('does not open a comment form when drag position exceeds buffer', () => { + return clickDragExplore( + { clientX: 0, clientY: 0 }, + { clientX: 10, clientY: 10 }, + { mouseup: true }, + ).then(() => { + expect(wrapper.emitted('openCommentForm')).toBeFalsy(); + }); + }); + + it('opens a comment form when drag position is within buffer', () => { + return clickDragExplore( + { clientX: 0, clientY: 0 }, + { clientX: 1, clientY: 0 }, + { mouseup: true }, + ).then(() => { + expect(wrapper.emitted('openCommentForm')).toBeDefined(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js new file mode 100644 index 00000000000..b06d2f924df --- /dev/null +++ b/spec/frontend/design_management/components/design_scaler_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignScaler from '~/design_management/components/design_scaler.vue'; + +describe('Design management design scaler component', () => { + let wrapper; + + function createComponent(propsData, data = {}) { + wrapper = shallowMount(DesignScaler, { + propsData, + }); + wrapper.setData(data); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const getButton = type => { + const buttonTypeOrder = ['minus', 'reset', 'plus']; + const buttons = wrapper.findAll('button'); + return buttons.at(buttonTypeOrder.indexOf(type)); + }; + + it('emits @scale event when "plus" button clicked', () => { + createComponent(); + + getButton('plus').trigger('click'); + expect(wrapper.emitted('scale')).toEqual([[1.2]]); + }); + + it('emits @scale event when "reset" button clicked (scale > 1)', () => { + createComponent({}, { scale: 1.6 }); + return wrapper.vm.$nextTick().then(() => { + getButton('reset').trigger('click'); + expect(wrapper.emitted('scale')).toEqual([[1]]); + }); + }); + + it('emits @scale event when "minus" button clicked (scale > 1)', () => { + createComponent({}, { scale: 1.6 }); + + return wrapper.vm.$nextTick().then(() => { + getButton('minus').trigger('click'); + expect(wrapper.emitted('scale')).toEqual([[1.4]]); + }); + }); + + it('minus and reset buttons are disabled when scale === 1', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('minus and reset buttons are enabled when scale > 1', () => { + createComponent({}, { scale: 1.2 }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('plus button is disabled when scale === 2', () => { + createComponent({}, { scale: 2 }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/design_management/components/image_spec.js b/spec/frontend/design_management/components/image_spec.js new file mode 100644 index 00000000000..52d60b04a8a --- /dev/null +++ b/spec/frontend/design_management/components/image_spec.js @@ -0,0 +1,133 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import DesignImage from '~/design_management/components/image.vue'; + +describe('Design management large image component', () => { + let wrapper; + + function createComponent(propsData, data = {}) { + wrapper = shallowMount(DesignImage, { + propsData, + }); + wrapper.setData(data); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders loading state', () => { + createComponent({ + isLoading: true, + }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders image', () => { + createComponent({ + isLoading: false, + image: 'test.jpg', + name: 'test', + }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('sets correct classes and styles if imageStyle is set', () => { + createComponent( + { + isLoading: false, + image: 'test.jpg', + name: 'test', + }, + { + imageStyle: { + width: '100px', + height: '100px', + }, + }, + ); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders media broken icon on error', () => { + createComponent({ + isLoading: false, + image: 'test.jpg', + name: 'test', + }); + + const image = wrapper.find('img'); + image.trigger('error'); + return wrapper.vm.$nextTick().then(() => { + expect(image.isVisible()).toBe(false); + expect(wrapper.find(GlIcon).element).toMatchSnapshot(); + }); + }); + + describe('zoom', () => { + const baseImageWidth = 100; + const baseImageHeight = 100; + + beforeEach(() => { + createComponent( + { + isLoading: false, + image: 'test.jpg', + name: 'test', + }, + { + imageStyle: { + width: `${baseImageWidth}px`, + height: `${baseImageHeight}px`, + }, + baseImageSize: { + width: baseImageWidth, + height: baseImageHeight, + }, + }, + ); + + jest.spyOn(wrapper.vm.$refs.contentImg, 'offsetWidth', 'get').mockReturnValue(baseImageWidth); + jest + .spyOn(wrapper.vm.$refs.contentImg, 'offsetHeight', 'get') + .mockReturnValue(baseImageHeight); + }); + + it('emits @resize event on zoom', () => { + const zoomAmount = 2; + wrapper.vm.zoom(zoomAmount); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('resize')).toEqual([ + [{ width: baseImageWidth * zoomAmount, height: baseImageHeight * zoomAmount }], + ]); + }); + }); + + it('emits @resize event with base image size when scale=1', () => { + wrapper.vm.zoom(1); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('resize')).toEqual([ + [{ width: baseImageWidth, height: baseImageHeight }], + ]); + }); + }); + + it('sets image style when zoomed', () => { + const zoomAmount = 2; + wrapper.vm.zoom(zoomAmount); + expect(wrapper.vm.imageStyle).toEqual({ + width: `${baseImageWidth * zoomAmount}px`, + height: `${baseImageHeight * zoomAmount}px`, + }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap new file mode 100644 index 00000000000..9cd427f6aae --- /dev/null +++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap @@ -0,0 +1,472 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management list item component when item appears in view after image is loaded renders media broken icon when image onerror triggered 1`] = ` + +`; + +exports[`Design management list item component with no notes renders item with correct status icon for creation event 1`] = ` + +
+
+ + + +
+ + + + + test + +
+ + +
+`; + +exports[`Design management list item component with no notes renders item with correct status icon for deletion event 1`] = ` + +
+
+ + + +
+ + + + + test + +
+ + +
+`; + +exports[`Design management list item component with no notes renders item with correct status icon for modification event 1`] = ` + +
+
+ + + +
+ + + + + test + +
+ + +
+`; + +exports[`Design management list item component with no notes renders item with no status icon for none event 1`] = ` + +
+ + + + + + test + +
+ + +
+`; + +exports[`Design management list item component with no notes renders loading spinner when isUploading is true 1`] = ` + +
+ + + + + + + +
+ + +
+`; + +exports[`Design management list item component with notes renders item with multiple comments 1`] = ` + +
+ + + + + + test + +
+ + +
+`; + +exports[`Design management list item component with notes renders item with single comment 1`] = ` + +
+ + + + + + test + +
+ + +
+`; diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js new file mode 100644 index 00000000000..705b532454f --- /dev/null +++ b/spec/frontend/design_management/components/list/item_spec.js @@ -0,0 +1,168 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; +import VueRouter from 'vue-router'; +import Item from '~/design_management/components/list/item.vue'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +const router = new VueRouter(); + +// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent +const DESIGN_VERSION_EVENT = { + CREATION: 'CREATION', + DELETION: 'DELETION', + MODIFICATION: 'MODIFICATION', + NO_CHANGE: 'NONE', +}; + +describe('Design management list item component', () => { + let wrapper; + + function createComponent({ + notesCount = 0, + event = DESIGN_VERSION_EVENT.NO_CHANGE, + isUploading = false, + imageLoading = false, + } = {}) { + wrapper = shallowMount(Item, { + localVue, + router, + propsData: { + id: 1, + filename: 'test', + image: 'http://via.placeholder.com/300', + isUploading, + event, + notesCount, + updatedAt: '01-01-2019', + }, + data() { + return { + imageLoading, + }; + }, + stubs: ['router-link'], + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when item is not in view', () => { + it('image is not rendered', () => { + createComponent(); + + const image = wrapper.find('img'); + expect(image.attributes('src')).toBe(''); + }); + }); + + describe('when item appears in view', () => { + let image; + let glIntersectionObserver; + + beforeEach(() => { + createComponent(); + image = wrapper.find('img'); + glIntersectionObserver = wrapper.find(GlIntersectionObserver); + + glIntersectionObserver.vm.$emit('appear'); + return wrapper.vm.$nextTick(); + }); + + describe('before image is loaded', () => { + it('renders loading spinner', () => { + expect(wrapper.find(GlLoadingIcon)).toExist(); + }); + }); + + describe('after image is loaded', () => { + beforeEach(() => { + image.trigger('load'); + return wrapper.vm.$nextTick(); + }); + + it('renders an image', () => { + expect(image.attributes('src')).toBe('http://via.placeholder.com/300'); + expect(image.isVisible()).toBe(true); + }); + + it('renders media broken icon when image onerror triggered', () => { + image.trigger('error'); + return wrapper.vm.$nextTick().then(() => { + expect(image.isVisible()).toBe(false); + expect(wrapper.find(GlIcon).element).toMatchSnapshot(); + }); + }); + + describe('when imageV432x230 and image provided', () => { + it('renders imageV432x230 image', () => { + const mockSrc = 'mock-imageV432x230-url'; + wrapper.setProps({ imageV432x230: mockSrc }); + + return wrapper.vm.$nextTick().then(() => { + expect(image.attributes('src')).toBe(mockSrc); + }); + }); + }); + + describe('when image disappears from view and then reappears', () => { + beforeEach(() => { + glIntersectionObserver.vm.$emit('appear'); + return wrapper.vm.$nextTick(); + }); + + it('renders an image', () => { + expect(image.isVisible()).toBe(true); + }); + }); + }); + }); + + describe('with notes', () => { + it('renders item with single comment', () => { + createComponent({ notesCount: 1 }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders item with multiple comments', () => { + createComponent({ notesCount: 2 }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('with no notes', () => { + it('renders item with no status icon for none event', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders item with correct status icon for modification event', () => { + createComponent({ event: DESIGN_VERSION_EVENT.MODIFICATION }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders item with correct status icon for deletion event', () => { + createComponent({ event: DESIGN_VERSION_EVENT.DELETION }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders item with correct status icon for creation event', () => { + createComponent({ event: DESIGN_VERSION_EVENT.CREATION }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders loading spinner when isUploading is true', () => { + createComponent({ isUploading: true }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap new file mode 100644 index 00000000000..e55cff8de3d --- /dev/null +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management toolbar component renders design and updated data 1`] = ` +
+ + + + +
+

+ test.jpg +

+ + + Updated 1 hour ago by Test Name + +
+ + + + + + + + + + +
+`; diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap new file mode 100644 index 00000000000..08662a04f15 --- /dev/null +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management pagination button component disables button when no design is passed 1`] = ` + + + +`; + +exports[`Design management pagination button component renders router-link 1`] = ` + + + +`; diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap new file mode 100644 index 00000000000..0197b4bff79 --- /dev/null +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management pagination component hides components when designs are empty 1`] = ``; + +exports[`Design management pagination component renders pagination buttons 1`] = ` +
+ + 0 of 2 + +
+ + + +
+
+`; diff --git a/spec/frontend/design_management/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js new file mode 100644 index 00000000000..2910b2f62ba --- /dev/null +++ b/spec/frontend/design_management/components/toolbar/index_spec.js @@ -0,0 +1,123 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import Toolbar from '~/design_management/components/toolbar/index.vue'; +import DeleteButton from '~/design_management/components/delete_button.vue'; +import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; +import { GlDeprecatedButton } from '@gitlab/ui'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +const router = new VueRouter(); + +const RouterLinkStub = { + props: { + to: { + type: Object, + }, + }, + render(createElement) { + return createElement('a', {}, this.$slots.default); + }, +}; + +describe('Design management toolbar component', () => { + let wrapper; + + function createComponent(isLoading = false, createDesign = true, props) { + const updatedAt = new Date(); + updatedAt.setHours(updatedAt.getHours() - 1); + + wrapper = shallowMount(Toolbar, { + localVue, + router, + propsData: { + id: '1', + isLatestVersion: true, + isLoading, + isDeleting: false, + filename: 'test.jpg', + updatedAt: updatedAt.toString(), + updatedBy: { + name: 'Test Name', + }, + image: '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d', + ...props, + }, + stubs: { + 'router-link': RouterLinkStub, + }, + }); + + wrapper.setData({ + permissions: { + createDesign, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders design and updated data', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('links back to designs list', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + const link = wrapper.find('a'); + + expect(link.props('to')).toEqual({ + name: DESIGNS_ROUTE_NAME, + query: { + version: undefined, + }, + }); + }); + }); + + it('renders delete button on latest designs version with logged in user', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DeleteButton).exists()).toBe(true); + }); + }); + + it('does not render delete button on non-latest version', () => { + createComponent(false, true, { isLatestVersion: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DeleteButton).exists()).toBe(false); + }); + }); + + it('does not render delete button when user is not logged in', () => { + createComponent(false, false); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DeleteButton).exists()).toBe(false); + }); + }); + + it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns'); + expect(wrapper.emitted().delete).toBeTruthy(); + }); + }); + + it('renders download button with correct link', () => { + expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe( + '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d', + ); + }); +}); diff --git a/spec/frontend/design_management/components/toolbar/pagination_button_spec.js b/spec/frontend/design_management/components/toolbar/pagination_button_spec.js new file mode 100644 index 00000000000..b7df201795b --- /dev/null +++ b/spec/frontend/design_management/components/toolbar/pagination_button_spec.js @@ -0,0 +1,61 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import PaginationButton from '~/design_management/components/toolbar/pagination_button.vue'; +import { DESIGN_ROUTE_NAME } from '~/design_management/router/constants'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +const router = new VueRouter(); + +describe('Design management pagination button component', () => { + let wrapper; + + function createComponent(design = null) { + wrapper = shallowMount(PaginationButton, { + localVue, + router, + propsData: { + design, + title: 'Test title', + iconName: 'angle-right', + }, + stubs: ['router-link'], + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('disables button when no design is passed', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders router-link', () => { + createComponent({ id: '2' }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('designLink', () => { + it('returns empty link when design is null', () => { + createComponent(); + + expect(wrapper.vm.designLink).toEqual({}); + }); + + it('returns design link', () => { + createComponent({ id: '2', filename: 'test' }); + + wrapper.vm.$router.replace('/root/test-project/issues/1/designs/test?version=1'); + + expect(wrapper.vm.designLink).toEqual({ + name: DESIGN_ROUTE_NAME, + params: { id: 'test' }, + query: { version: '1' }, + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/toolbar/pagination_spec.js b/spec/frontend/design_management/components/toolbar/pagination_spec.js new file mode 100644 index 00000000000..db5a36dadf6 --- /dev/null +++ b/spec/frontend/design_management/components/toolbar/pagination_spec.js @@ -0,0 +1,79 @@ +/* global Mousetrap */ +import 'mousetrap'; +import { shallowMount } from '@vue/test-utils'; +import Pagination from '~/design_management/components/toolbar/pagination.vue'; +import { DESIGN_ROUTE_NAME } from '~/design_management/router/constants'; + +const push = jest.fn(); +const $router = { + push, +}; + +const $route = { + path: '/designs/design-2', + query: {}, +}; + +describe('Design management pagination component', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(Pagination, { + propsData: { + id: '2', + }, + mocks: { + $router, + $route, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('hides components when designs are empty', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders pagination buttons', () => { + wrapper.setData({ + designs: [{ id: '1' }, { id: '2' }], + }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('keyboard buttons navigation', () => { + beforeEach(() => { + wrapper.setData({ + designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }], + }); + }); + + it('routes to previous design on Left button', () => { + Mousetrap.trigger('left'); + expect(push).toHaveBeenCalledWith({ + name: DESIGN_ROUTE_NAME, + params: { id: '1' }, + query: {}, + }); + }); + + it('routes to next design on Right button', () => { + Mousetrap.trigger('right'); + expect(push).toHaveBeenCalledWith({ + name: DESIGN_ROUTE_NAME, + params: { id: '3' }, + query: {}, + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap new file mode 100644 index 00000000000..185bf4a48f7 --- /dev/null +++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management upload button component renders inverted upload design button 1`] = ` +
+ + + Add designs + + + + + +
+`; + +exports[`Design management upload button component renders loading icon 1`] = ` +
+ + + Add designs + + + + + +
+`; + +exports[`Design management upload button component renders upload design button 1`] = ` +
+ + + Add designs + + + + + +
+`; diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap new file mode 100644 index 00000000000..0737b9729a2 --- /dev/null +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap @@ -0,0 +1,455 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management dropzone component when dragging renders correct template when drag event contains files 1`] = ` +
+ + + + + +
+ + +
+

+ Incoming! +

+ + + Drop your designs to start your upload. + +
+
+
+
+`; + +exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = ` +
+ + + + + +
+ + +
+

+ Incoming! +

+ + + Drop your designs to start your upload. + +
+
+
+
+`; + +exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = ` +
+ + + + + +
+
+

+ Oh no! +

+ + + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + +
+ + +
+
+
+`; + +exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = ` +
+ + + + + +
+
+

+ Oh no! +

+ + + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + +
+ + +
+
+
+`; + +exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = ` +
+ + + + + + + +
+`; + +exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = ` +
+ + + + + + + +
+`; + +exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = ` +
+
+ dropzone slot +
+ + + + +
+`; diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap new file mode 100644 index 00000000000..00f1a40dfb2 --- /dev/null +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` + + + +
+
+ + Version 2 + + + (latest) + + +
+
+ + +
+
+ + +
+
+ + Version 1 + + + +
+
+ + +
+
+
+`; + +exports[`Design management design version dropdown component renders design version list 1`] = ` + + + +
+
+ + Version 2 + + + (latest) + + +
+
+ + +
+
+ + +
+
+ + Version 1 + + + +
+
+ + +
+
+
+`; diff --git a/spec/frontend/design_management/components/upload/button_spec.js b/spec/frontend/design_management/components/upload/button_spec.js new file mode 100644 index 00000000000..c0a9693dc37 --- /dev/null +++ b/spec/frontend/design_management/components/upload/button_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import UploadButton from '~/design_management/components/upload/button.vue'; + +describe('Design management upload button component', () => { + let wrapper; + + function createComponent(isSaving = false, isInverted = false) { + wrapper = shallowMount(UploadButton, { + propsData: { + isSaving, + isInverted, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders upload design button', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders inverted upload design button', () => { + createComponent(false, true); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders loading icon', () => { + createComponent(true); + + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('onFileUploadChange', () => { + it('emits upload event', () => { + createComponent(); + + wrapper.vm.onFileUploadChange({ target: { files: 'test' } }); + + expect(wrapper.emitted().upload[0]).toEqual(['test']); + }); + }); + + describe('openFileUpload', () => { + it('triggers click on input', () => { + createComponent(); + + const clickSpy = jest.spyOn(wrapper.find('input').element, 'click'); + + wrapper.vm.openFileUpload(); + + expect(clickSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/design_management/components/upload/design_dropzone_spec.js b/spec/frontend/design_management/components/upload/design_dropzone_spec.js new file mode 100644 index 00000000000..9b86b5b2878 --- /dev/null +++ b/spec/frontend/design_management/components/upload/design_dropzone_spec.js @@ -0,0 +1,132 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); + +describe('Design management dropzone component', () => { + let wrapper; + + const mockDragEvent = ({ types = ['Files'], files = [] }) => { + return { dataTransfer: { types, files } }; + }; + + const findDropzoneCard = () => wrapper.find('.design-dropzone-card'); + + function createComponent({ slots = {}, data = {} } = {}) { + wrapper = shallowMount(DesignDropzone, { + slots, + data() { + return data; + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when slot provided', () => { + it('renders dropzone with slot content', () => { + createComponent({ + slots: { + default: ['
dropzone slot
'], + }, + }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('when no slot provided', () => { + it('renders default dropzone card', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('triggers click event on file input element when clicked', () => { + createComponent(); + const clickSpy = jest.spyOn(wrapper.find('input').element, 'click'); + + findDropzoneCard().trigger('click'); + expect(clickSpy).toHaveBeenCalled(); + }); + }); + + describe('when dragging', () => { + it.each` + description | eventPayload + ${'is empty'} | ${{}} + ${'contains text'} | ${mockDragEvent({ types: ['text'] })} + ${'contains files and text'} | ${mockDragEvent({ types: ['Files', 'text'] })} + ${'contains files'} | ${mockDragEvent({ types: ['Files'] })} + `('renders correct template when drag event $description', ({ eventPayload }) => { + createComponent(); + + wrapper.trigger('dragenter', eventPayload); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders correct template when dragging stops', () => { + createComponent(); + + wrapper.trigger('dragenter'); + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.trigger('dragleave'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + }); + + describe('when dropping', () => { + it('emits upload event', () => { + createComponent(); + const mockFile = { name: 'test', type: 'image/jpg' }; + const mockEvent = mockDragEvent({ files: [mockFile] }); + + wrapper.trigger('dragenter', mockEvent); + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.trigger('drop', mockEvent); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); + }); + }); + }); + + describe('ondrop', () => { + const mockData = { dragCounter: 1, isDragDataValid: true }; + + describe('when drag data is valid', () => { + it('emits upload event for valid files', () => { + createComponent({ data: mockData }); + + const mockFile = { type: 'image/jpg' }; + const mockEvent = mockDragEvent({ files: [mockFile] }); + + wrapper.vm.ondrop(mockEvent); + expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); + }); + + it('calls createFlash when files are invalid', () => { + createComponent({ data: mockData }); + + const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] }); + + wrapper.vm.ondrop(mockEvent); + expect(createFlash).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js new file mode 100644 index 00000000000..7521b9fad2a --- /dev/null +++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js @@ -0,0 +1,114 @@ +import { shallowMount } from '@vue/test-utils'; +import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import mockAllVersions from './mock_data/all_versions'; + +const LATEST_VERSION_ID = 3; +const PREVIOUS_VERSION_ID = 2; + +const designRouteFactory = versionId => ({ + path: `/designs?version=${versionId}`, + query: { + version: `${versionId}`, + }, +}); + +const MOCK_ROUTE = { + path: '/designs', + query: {}, +}; + +describe('Design management design version dropdown component', () => { + let wrapper; + + function createComponent({ maxVersions = -1, $route = MOCK_ROUTE } = {}) { + wrapper = shallowMount(DesignVersionDropdown, { + propsData: { + projectPath: '', + issueIid: '', + }, + mocks: { + $route, + }, + stubs: ['router-link'], + }); + + wrapper.setData({ + allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findVersionLink = index => wrapper.findAll('.js-version-link').at(index); + + it('renders design version dropdown button', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders design version list', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('selected version name', () => { + it('has "latest" on most recent version item', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(findVersionLink(0).text()).toContain('latest'); + }); + }); + }); + + describe('versions list', () => { + it('displays latest version text by default', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version'); + }); + }); + + it('displays latest version text when only 1 version is present', () => { + createComponent({ maxVersions: 1 }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version'); + }); + }); + + it('displays version text when the current version is not the latest', () => { + createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlDropdown).attributes('text')).toBe(`Showing Version #1`); + }); + }); + + it('displays latest version text when the current version is the latest', () => { + createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version'); + }); + }); + + it('should have the same length as apollo query', () => { + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/upload/mock_data/all_versions.js b/spec/frontend/design_management/components/upload/mock_data/all_versions.js new file mode 100644 index 00000000000..e76bbd261bd --- /dev/null +++ b/spec/frontend/design_management/components/upload/mock_data/all_versions.js @@ -0,0 +1,14 @@ +export default [ + { + node: { + id: 'gid://gitlab/DesignManagement::Version/3', + sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55', + }, + }, + { + node: { + id: 'gid://gitlab/DesignManagement::Version/2', + sha: '5b063fef0cd7213b312db65b30e24f057df21b20', + }, + }, +]; -- cgit v1.2.1