diff options
Diffstat (limited to 'spec/frontend/design_management')
44 files changed, 5933 insertions, 0 deletions
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`] = ` +<button + aria-label="Comment form position" + class="position-absolute btn-transparent comment-indicator" + style="left: 10px; top: 10px; cursor: move;" + type="button" +> + <icon-stub + name="image-comment-dark" + size="16" + /> +</button> +`; + +exports[`Design discussions component should match the snapshot of note with index 1`] = ` +<button + aria-label="Comment '1' position" + class="position-absolute js-image-badge badge badge-pill" + style="left: 10px; top: 10px;" + type="button" +> + + 1 + +</button> +`; + +exports[`Design discussions component should match the snapshot of note without index 1`] = ` +<button + aria-label="Comment form position" + class="position-absolute btn-transparent comment-indicator" + style="left: 10px; top: 10px;" + type="button" +> + <icon-stub + name="image-comment-dark" + size="16" + /> +</button> +`; 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`] = ` +<div + class="h-100 w-100 p-3 overflow-auto position-relative" +> + <div + class="h-100 w-100 d-flex align-items-center position-relative" + > + <design-image-stub + image="test.jpg" + name="test" + scale="1" + /> + + <design-overlay-stub + currentcommentform="[object Object]" + dimensions="[object Object]" + notes="" + position="[object Object]" + /> + </div> +</div> +`; + +exports[`Design management design presentation component currentCommentForm is null when isAnnotating is false 1`] = ` +<div + class="h-100 w-100 p-3 overflow-auto position-relative" +> + <div + class="h-100 w-100 d-flex align-items-center position-relative" + > + <design-image-stub + image="test.jpg" + name="test" + scale="1" + /> + + <design-overlay-stub + dimensions="[object Object]" + notes="" + position="[object Object]" + /> + </div> +</div> +`; + +exports[`Design management design presentation component currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = ` +<div + class="h-100 w-100 p-3 overflow-auto position-relative" +> + <div + class="h-100 w-100 d-flex align-items-center position-relative" + > + <design-image-stub + image="test.jpg" + name="test" + scale="1" + /> + + <design-overlay-stub + dimensions="[object Object]" + notes="" + position="[object Object]" + /> + </div> +</div> +`; + +exports[`Design management design presentation component renders empty state when no image provided 1`] = ` +<div + class="h-100 w-100 p-3 overflow-auto position-relative" +> + <div + class="h-100 w-100 d-flex align-items-center position-relative" + > + <!----> + + <!----> + </div> +</div> +`; + +exports[`Design management design presentation component renders image and overlay when image provided 1`] = ` +<div + class="h-100 w-100 p-3 overflow-auto position-relative" +> + <div + class="h-100 w-100 d-flex align-items-center position-relative" + > + <design-image-stub + image="test.jpg" + name="test" + scale="1" + /> + + <design-overlay-stub + dimensions="[object Object]" + notes="" + position="[object Object]" + /> + </div> +</div> +`; 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`] = ` +<div + class="design-scaler btn-group" + role="group" +> + <button + class="btn" + disabled="disabled" + > + <span + class="d-flex-center gl-icon s16" + > + + – + + </span> + </button> + + <button + class="btn" + disabled="disabled" + > + <gl-icon-stub + name="redo" + size="16" + /> + </button> + + <button + class="btn" + > + <gl-icon-stub + name="plus" + size="16" + /> + </button> +</div> +`; + +exports[`Design management design scaler component minus and reset buttons are enabled when scale > 1 1`] = ` +<div + class="design-scaler btn-group" + role="group" +> + <button + class="btn" + > + <span + class="d-flex-center gl-icon s16" + > + + – + + </span> + </button> + + <button + class="btn" + > + <gl-icon-stub + name="redo" + size="16" + /> + </button> + + <button + class="btn" + > + <gl-icon-stub + name="plus" + size="16" + /> + </button> +</div> +`; + +exports[`Design management design scaler component plus button is disabled when scale === 2 1`] = ` +<div + class="design-scaler btn-group" + role="group" +> + <button + class="btn" + > + <span + class="d-flex-center gl-icon s16" + > + + – + + </span> + </button> + + <button + class="btn" + > + <gl-icon-stub + name="redo" + size="16" + /> + </button> + + <button + class="btn" + disabled="disabled" + > + <gl-icon-stub + name="plus" + size="16" + /> + </button> +</div> +`; 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`] = ` +<div + class="m-auto js-design-image" +> + <!----> + + <img + alt="test" + class="mh-100 img-fluid" + src="test.jpg" + /> +</div> +`; + +exports[`Design management large image component renders loading state 1`] = ` +<div + class="m-auto js-design-image" + isloading="true" +> + <!----> + + <img + alt="" + class="mh-100 img-fluid" + src="" + /> +</div> +`; + +exports[`Design management large image component renders media broken icon on error 1`] = ` +<gl-icon-stub + class="text-secondary-100" + name="media-broken" + size="48" +/> +`; + +exports[`Design management large image component sets correct classes and styles if imageStyle is set 1`] = ` +<div + class="m-auto js-design-image" +> + <!----> + + <img + alt="test" + class="mh-100" + src="test.jpg" + style="width: 100px; height: 100px;" + /> +</div> +`; + +exports[`Design management large image component zoom sets image style when zoomed 1`] = ` +<div + class="m-auto js-design-image" +> + <!----> + + <img + alt="test" + class="mh-100" + src="test.jpg" + style="width: 200px; height: 200px;" + /> +</div> +`; 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`] = ` +<timeline-entry-item-stub + class="design-note note-form" + id="note_123" +> + <user-avatar-link-stub + imgalt="" + imgcssclasses="" + imgsize="40" + imgsrc="" + linkhref="" + tooltipplacement="top" + tooltiptext="" + username="" + /> + + <div + class="d-flex justify-content-between" + > + <div> + <a + class="js-user-link" + data-user-id="author-id" + > + <span + class="note-header-author-name bold" + > + + </span> + + <!----> + + <span + class="note-headline-light" + > + @ + </span> + </a> + + <span + class="note-headline-light note-headline-meta" + > + <span + class="system-note-message" + /> + + <!----> + </span> + </div> + + <!----> + </div> + + <div + class="note-text js-note-text md" + data-qa-selector="note_content" + /> +</timeline-entry-item-stub> +`; 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`] = ` +"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\"> + <!----> + Comment +</button>" +`; + +exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = ` +"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\"> + <!----> + Save comment +</button>" +`; 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: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', + 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`] = ` +<gl-icon-stub + class="text-secondary" + name="media-broken" + size="32" +/> +`; + +exports[`Design management list item component with no notes renders item with correct status icon for creation event 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <div + class="design-event position-absolute" + > + <span + aria-label="Added in this version" + title="Added in this version" + > + <icon-stub + class="text-success-500" + name="file-addition-solid" + size="18" + /> + </span> + </div> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <!----> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with no notes renders item with correct status icon for deletion event 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <div + class="design-event position-absolute" + > + <span + aria-label="Deleted in this version" + title="Deleted in this version" + > + <icon-stub + class="text-danger-500" + name="file-deletion-solid" + size="18" + /> + </span> + </div> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <!----> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with no notes renders item with correct status icon for modification event 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <div + class="design-event position-absolute" + > + <span + aria-label="Modified in this version" + title="Modified in this version" + > + <icon-stub + class="text-primary-500" + name="file-modified-solid" + size="18" + /> + </span> + </div> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <!----> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with no notes renders item with no status icon for none event 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <!----> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <!----> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with no notes renders loading spinner when isUploading is true 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <!----> + + <gl-intersection-observer-stub + options="[object Object]" + > + <gl-loading-icon-stub + color="orange" + label="Loading" + size="md" + /> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + style="display: none;" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <!----> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with notes renders item with multiple comments 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <!----> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <div + class="ml-auto d-flex align-items-center text-secondary" + > + <icon-stub + class="ml-1" + name="comments" + size="16" + /> + + <span + aria-label="2 comments" + class="ml-1" + > + + 2 + + </span> + </div> + </div> +</router-link-stub> +`; + +exports[`Design management list item component with notes renders item with single comment 1`] = ` +<router-link-stub + class="card cursor-pointer text-plain js-design-list-item design-list-item" + to="[object Object]" +> + <div + class="card-body p-0 d-flex-center overflow-hidden position-relative" + > + <!----> + + <gl-intersection-observer-stub + options="[object Object]" + > + <!----> + + <img + alt="test" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + src="" + /> + </gl-intersection-observer-stub> + </div> + + <div + class="card-footer d-flex w-100" + > + <div + class="d-flex flex-column str-truncated-100" + > + <span + class="bold str-truncated-100" + data-qa-selector="design_file_name" + > + test + </span> + + <span + class="str-truncated-100" + > + + Updated + <timeago-stub + cssclass="" + time="01-01-2019" + tooltipplacement="bottom" + /> + </span> + </div> + + <div + class="ml-auto d-flex align-items-center text-secondary" + > + <icon-stub + class="ml-1" + name="comments" + size="16" + /> + + <span + aria-label="1 comment" + class="ml-1" + > + + 1 + + </span> + </div> + </div> +</router-link-stub> +`; 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`] = ` +<header + class="d-flex p-2 bg-white align-items-center js-design-header" +> + <a + aria-label="Go back to designs" + class="mr-3 text-plain d-flex justify-content-center align-items-center" + > + <icon-stub + name="close" + size="18" + /> + </a> + + <div + class="overflow-hidden d-flex align-items-center" + > + <h2 + class="m-0 str-truncated-100 gl-font-base" + > + test.jpg + </h2> + + <small + class="text-secondary" + > + Updated 1 hour ago by Test Name + </small> + </div> + + <pagination-stub + class="ml-auto flex-shrink-0" + id="1" + /> + + <gl-deprecated-button-stub + class="mr-2" + href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d" + size="md" + variant="secondary" + > + <icon-stub + name="download" + size="18" + /> + </gl-deprecated-button-stub> + + <delete-button-stub + buttonclass="" + buttonvariant="danger" + hasselecteddesigns="true" + > + <icon-stub + name="remove" + size="18" + /> + </delete-button-stub> +</header> +`; 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`] = ` +<router-link-stub + aria-label="Test title" + class="btn btn-default disabled" + disabled="true" + to="[object Object]" +> + <icon-stub + name="angle-right" + size="16" + /> +</router-link-stub> +`; + +exports[`Design management pagination button component renders router-link 1`] = ` +<router-link-stub + aria-label="Test title" + class="btn btn-default" + to="[object Object]" +> + <icon-stub + name="angle-right" + size="16" + /> +</router-link-stub> +`; 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`] = ` +<div + class="d-flex align-items-center" +> + + 0 of 2 + + <div + class="btn-group ml-3 mr-3" + > + <pagination-button-stub + class="js-previous-design" + iconname="angle-left" + title="Go to previous design" + /> + + <pagination-button-stub + class="js-next-design" + design="[object Object]" + iconname="angle-right" + title="Go to next design" + /> + </div> +</div> +`; 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`] = ` +<div + isinverted="true" +> + <gl-deprecated-button-stub + size="md" + title="Adding a design with the same filename replaces the file in a new version." + variant="success" + > + + Add designs + + <!----> + </gl-deprecated-button-stub> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> +</div> +`; + +exports[`Design management upload button component renders loading icon 1`] = ` +<div> + <gl-deprecated-button-stub + disabled="true" + size="md" + title="Adding a design with the same filename replaces the file in a new version." + variant="success" + > + + Add designs + + <gl-loading-icon-stub + class="ml-1" + color="orange" + inline="true" + label="Loading" + size="sm" + /> + </gl-deprecated-button-stub> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> +</div> +`; + +exports[`Design management upload button component renders upload design button 1`] = ` +<div> + <gl-deprecated-button-stub + size="md" + title="Adding a design with the same filename replaces the file in a new version." + variant="success" + > + + Add designs + + <!----> + </gl-deprecated-button-stub> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> +</div> +`; 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`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="" + > + <div + class="mw-50 text-center" + style="display: none;" + > + <h3> + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="" + > + <h3> + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="" + > + <div + class="mw-50 text-center" + style="display: none;" + > + <h3> + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="" + > + <h3> + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="" + > + <div + class="mw-50 text-center" + > + <h3> + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="display: none;" + > + <h3> + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="" + > + <div + class="mw-50 text-center" + > + <h3> + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="display: none;" + > + <h3> + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="display: none;" + > + <div + class="mw-50 text-center" + > + <h3> + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="display: none;" + > + <h3> + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = ` +<div + class="w-100 position-relative" +> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + > + <div + class="d-flex-center flex-column text-center" + > + <gl-icon-stub + class="mb-4" + name="doc-new" + size="48" + /> + + <p> + <gl-sprintf-stub + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="design_file" + type="file" + /> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="display: none;" + > + <div + class="mw-50 text-center" + > + <h3> + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="display: none;" + > + <h3> + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = ` +<div + class="w-100 position-relative" +> + <div> + dropzone slot + </div> + + <transition-stub + name="design-dropzone-fade" + > + <div + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + style="display: none;" + > + <div + class="mw-50 text-center" + > + <h3> + Oh no! + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 text-center" + style="display: none;" + > + <h3> + Incoming! + </h3> + + <span> + Drop your designs to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; 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`] = ` +<gl-dropdown-stub + class="design-version-dropdown" + issueiid="" + projectpath="" + text="Showing Latest Version" + variant="link" +> + <gl-dropdown-item-stub> + <router-link-stub + class="d-flex js-version-link" + to="[object Object]" + > + <div + class="flex-grow-1 ml-2" + > + <div> + <strong> + Version 2 + + <span> + (latest) + </span> + </strong> + </div> + </div> + + <i + class="fa fa-check pull-right" + /> + </router-link-stub> + </gl-dropdown-item-stub> + <gl-dropdown-item-stub> + <router-link-stub + class="d-flex js-version-link" + to="[object Object]" + > + <div + class="flex-grow-1 ml-2" + > + <div> + <strong> + Version 1 + + <!----> + </strong> + </div> + </div> + + <!----> + </router-link-stub> + </gl-dropdown-item-stub> +</gl-dropdown-stub> +`; + +exports[`Design management design version dropdown component renders design version list 1`] = ` +<gl-dropdown-stub + class="design-version-dropdown" + issueiid="" + projectpath="" + text="Showing Latest Version" + variant="link" +> + <gl-dropdown-item-stub> + <router-link-stub + class="d-flex js-version-link" + to="[object Object]" + > + <div + class="flex-grow-1 ml-2" + > + <div> + <strong> + Version 2 + + <span> + (latest) + </span> + </strong> + </div> + </div> + + <i + class="fa fa-check pull-right" + /> + </router-link-stub> + </gl-dropdown-item-stub> + <gl-dropdown-item-stub> + <router-link-stub + class="d-flex js-version-link" + to="[object Object]" + > + <div + class="flex-grow-1 ml-2" + > + <div> + <strong> + Version 1 + + <!----> + </strong> + </div> + </div> + + <!----> + </router-link-stub> + </gl-dropdown-item-stub> +</gl-dropdown-stub> +`; 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: ['<div>dropzone slot</div>'], + }, + }); + + 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', + }, + }, +]; diff --git a/spec/frontend/design_management/mock_data/all_versions.js b/spec/frontend/design_management/mock_data/all_versions.js new file mode 100644 index 00000000000..c389fdb8747 --- /dev/null +++ b/spec/frontend/design_management/mock_data/all_versions.js @@ -0,0 +1,8 @@ +export default [ + { + node: { + id: 'gid://gitlab/DesignManagement::Version/1', + sha: 'b389071a06c153509e11da1f582005b316667001', + }, + }, +]; diff --git a/spec/frontend/design_management/mock_data/design.js b/spec/frontend/design_management/mock_data/design.js new file mode 100644 index 00000000000..34e3077f4a2 --- /dev/null +++ b/spec/frontend/design_management/mock_data/design.js @@ -0,0 +1,54 @@ +export default { + id: 'design-id', + filename: 'test.jpg', + fullPath: 'full-design-path', + image: 'test.jpg', + updatedAt: '01-01-2019', + updatedBy: { + name: 'test', + }, + issue: { + title: 'My precious issue', + webPath: 'full-issue-path', + webUrl: 'full-issue-url', + participants: { + edges: [ + { + node: { + name: 'Administrator', + username: 'root', + webUrl: 'link-to-author', + avatarUrl: 'link-to-avatar', + }, + }, + ], + }, + }, + discussions: { + nodes: [ + { + id: 'discussion-id', + replyId: 'discussion-reply-id', + notes: { + nodes: [ + { + id: 'note-id', + body: '123', + author: { + name: 'Administrator', + username: 'root', + webUrl: 'link-to-author', + avatarUrl: 'link-to-avatar', + }, + }, + ], + }, + }, + ], + }, + diffRefs: { + headSha: 'headSha', + baseSha: 'baseSha', + startSha: 'startSha', + }, +}; diff --git a/spec/frontend/design_management/mock_data/designs.js b/spec/frontend/design_management/mock_data/designs.js new file mode 100644 index 00000000000..07f5c1b7457 --- /dev/null +++ b/spec/frontend/design_management/mock_data/designs.js @@ -0,0 +1,17 @@ +import design from './design'; + +export default { + project: { + issue: { + designCollection: { + designs: { + edges: [ + { + node: design, + }, + ], + }, + }, + }, + }, +}; diff --git a/spec/frontend/design_management/mock_data/no_designs.js b/spec/frontend/design_management/mock_data/no_designs.js new file mode 100644 index 00000000000..9db0ffcade2 --- /dev/null +++ b/spec/frontend/design_management/mock_data/no_designs.js @@ -0,0 +1,11 @@ +export default { + project: { + issue: { + designCollection: { + designs: { + edges: [], + }, + }, + }, + }, +}; diff --git a/spec/frontend/design_management/mock_data/notes.js b/spec/frontend/design_management/mock_data/notes.js new file mode 100644 index 00000000000..db4624c8524 --- /dev/null +++ b/spec/frontend/design_management/mock_data/notes.js @@ -0,0 +1,32 @@ +export default [ + { + id: 'note-id-1', + position: { + height: 100, + width: 100, + x: 10, + y: 15, + }, + userPermissions: { + adminNote: true, + }, + discussion: { + id: 'discussion-id-1', + }, + }, + { + id: 'note-id-2', + position: { + height: 50, + width: 50, + x: 25, + y: 25, + }, + userPermissions: { + adminNote: true, + }, + discussion: { + id: 'discussion-id-2', + }, + }, +]; diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap new file mode 100644 index 00000000000..3ba63fd14f0 --- /dev/null +++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap @@ -0,0 +1,263 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management index page designs does not render toolbar when there is no permission 1`] = ` +<div> + <!----> + + <div + class="mt-4" + > + <ol + class="list-unstyled row" + > + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub + class="design-list-item" + /> + </li> + + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub> + <design-stub + event="NONE" + filename="design-1-name" + id="design-1" + image="design-1-image" + notescount="0" + /> + </design-dropzone-stub> + + <!----> + </li> + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub> + <design-stub + event="NONE" + filename="design-2-name" + id="design-2" + image="design-2-image" + notescount="1" + /> + </design-dropzone-stub> + + <!----> + </li> + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub> + <design-stub + event="NONE" + filename="design-3-name" + id="design-3" + image="design-3-image" + notescount="0" + /> + </design-dropzone-stub> + + <!----> + </li> + </ol> + </div> + + <router-view-stub + name="default" + /> +</div> +`; + +exports[`Design management index page designs renders designs list and header with upload button 1`] = ` +<div> + <header + class="row-content-block border-top-0 p-2 d-flex" + > + <div + class="d-flex justify-content-between align-items-center w-100" + > + <design-version-dropdown-stub /> + + <div + class="qa-selector-toolbar d-flex" + > + <gl-deprecated-button-stub + class="mr-2 js-select-all" + size="md" + variant="link" + > + Select all + </gl-deprecated-button-stub> + + <div> + <delete-button-stub + buttonclass="btn-danger btn-inverted mr-2" + buttonvariant="" + > + + Delete selected + + <!----> + </delete-button-stub> + </div> + + <upload-button-stub /> + </div> + </div> + </header> + + <div + class="mt-4" + > + <ol + class="list-unstyled row" + > + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub + class="design-list-item" + /> + </li> + + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub> + <design-stub + event="NONE" + filename="design-1-name" + id="design-1" + image="design-1-image" + notescount="0" + /> + </design-dropzone-stub> + + <input + class="design-checkbox" + type="checkbox" + /> + </li> + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub> + <design-stub + event="NONE" + filename="design-2-name" + id="design-2" + image="design-2-image" + notescount="1" + /> + </design-dropzone-stub> + + <input + class="design-checkbox" + type="checkbox" + /> + </li> + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub> + <design-stub + event="NONE" + filename="design-3-name" + id="design-3" + image="design-3-image" + notescount="0" + /> + </design-dropzone-stub> + + <input + class="design-checkbox" + type="checkbox" + /> + </li> + </ol> + </div> + + <router-view-stub + name="default" + /> +</div> +`; + +exports[`Design management index page designs renders error 1`] = ` +<div> + <!----> + + <div + class="mt-4" + > + <gl-alert-stub + dismisslabel="Dismiss" + primarybuttonlink="" + primarybuttontext="" + secondarybuttonlink="" + secondarybuttontext="" + title="" + variant="danger" + > + + An error occurred while loading designs. Please try again. + + </gl-alert-stub> + </div> + + <router-view-stub + name="default" + /> +</div> +`; + +exports[`Design management index page designs renders loading icon 1`] = ` +<div> + <!----> + + <div + class="mt-4" + > + <gl-loading-icon-stub + color="orange" + label="Loading" + size="md" + /> + </div> + + <router-view-stub + name="default" + /> +</div> +`; + +exports[`Design management index page when has no designs renders empty text 1`] = ` +<div> + <!----> + + <div + class="mt-4" + > + <ol + class="list-unstyled row" + > + <li + class="col-md-6 col-lg-4 mb-3" + > + <design-dropzone-stub + class="design-list-item" + /> + </li> + + </ol> + </div> + + <router-view-stub + name="default" + /> +</div> +`; diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap new file mode 100644 index 00000000000..76e481ee518 --- /dev/null +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -0,0 +1,184 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Design management design index page renders design index 1`] = ` +<div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" +> + <div + class="d-flex overflow-hidden flex-grow-1 flex-column position-relative" + > + <design-destroyer-stub + filenames="test.jpg" + iid="1" + projectpath="" + /> + + <!----> + + <design-presentation-stub + discussions="[object Object]" + image="test.jpg" + imagename="test.jpg" + scale="1" + /> + + <div + class="design-scaler-wrapper position-absolute mb-4 d-flex-center" + > + <design-scaler-stub /> + </div> + </div> + + <div + class="image-notes" + > + <h2 + class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0" + > + + My precious issue + + </h2> + + <a + class="text-tertiary text-decoration-none mb-3 d-block" + href="full-issue-url" + > + ull-issue-path + </a> + + <participants-stub + class="mb-4" + numberoflessparticipants="7" + participants="[object Object]" + /> + + <div + class="design-discussion-wrapper" + > + <div + class="badge badge-pill" + type="button" + > + 1 + </div> + + <div + class="design-discussion bordered-box position-relative" + data-qa-selector="design_discussion_content" + > + <design-note-stub + class="" + markdownpreviewpath="//preview_markdown?target_type=Issue" + note="[object Object]" + /> + + <div + class="reply-wrapper" + > + <reply-placeholder-stub + buttontext="Reply..." + class="qa-discussion-reply" + /> + </div> + </div> + </div> + + <!----> + </div> +</div> +`; + +exports[`Design management design index page sets loading state 1`] = ` +<div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" +> + <gl-loading-icon-stub + class="align-self-center" + color="orange" + label="Loading" + size="xl" + /> +</div> +`; + +exports[`Design management design index page with error GlAlert is rendered in correct position with correct content 1`] = ` +<div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" +> + <div + class="d-flex overflow-hidden flex-grow-1 flex-column position-relative" + > + <design-destroyer-stub + filenames="test.jpg" + iid="1" + projectpath="" + /> + + <div + class="p-3" + > + <gl-alert-stub + dismissible="true" + dismisslabel="Dismiss" + primarybuttonlink="" + primarybuttontext="" + secondarybuttonlink="" + secondarybuttontext="" + title="" + variant="danger" + > + + woops + + </gl-alert-stub> + </div> + + <design-presentation-stub + discussions="" + image="test.jpg" + imagename="test.jpg" + scale="1" + /> + + <div + class="design-scaler-wrapper position-absolute mb-4 d-flex-center" + > + <design-scaler-stub /> + </div> + </div> + + <div + class="image-notes" + > + <h2 + class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0" + > + + My precious issue + + </h2> + + <a + class="text-tertiary text-decoration-none mb-3 d-block" + href="full-issue-url" + > + ull-issue-path + </a> + + <participants-stub + class="mb-4" + numberoflessparticipants="7" + participants="[object Object]" + /> + + <h2 + class="new-discussion-disclaimer gl-font-base m-0" + > + + Click the image where you'd like to start a new discussion + + </h2> + </div> +</div> +`; diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js new file mode 100644 index 00000000000..9e2f071a983 --- /dev/null +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -0,0 +1,301 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; +import { ApolloMutation } from 'vue-apollo'; +import createFlash from '~/flash'; +import DesignIndex from '~/design_management/pages/design/index.vue'; +import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; +import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; +import Participants from '~/sidebar/components/participants/participants.vue'; +import createImageDiffNoteMutation from '~/design_management/graphql/mutations/createImageDiffNote.mutation.graphql'; +import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql'; +import design from '../../mock_data/design'; +import mockResponseWithDesigns from '../../mock_data/designs'; +import mockResponseNoDesigns from '../../mock_data/no_designs'; +import mockAllVersions from '../../mock_data/all_versions'; +import { + DESIGN_NOT_FOUND_ERROR, + DESIGN_VERSION_NOT_EXIST_ERROR, +} from '~/design_management/utils/error_messages'; +import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; + +jest.mock('~/flash'); +jest.mock('mousetrap', () => ({ + bind: jest.fn(), + unbind: jest.fn(), +})); + +describe('Design management design index page', () => { + let wrapper; + const newComment = 'new comment'; + const annotationCoordinates = { + x: 10, + y: 10, + width: 100, + height: 100, + }; + const createDiscussionMutationVariables = { + mutation: createImageDiffNoteMutation, + update: expect.anything(), + variables: { + input: { + body: newComment, + noteableId: design.id, + position: { + headSha: 'headSha', + baseSha: 'baseSha', + startSha: 'startSha', + paths: { + newPath: 'full-design-path', + }, + ...annotationCoordinates, + }, + }, + }, + }; + + const updateActiveDiscussionMutationVariables = { + mutation: updateActiveDiscussionMutation, + variables: { + id: design.discussions.nodes[0].notes.nodes[0].id, + source: 'discussion', + }, + }; + + const mutate = jest.fn().mockResolvedValue(); + const routerPush = jest.fn(); + + const findDiscussions = () => wrapper.findAll(DesignDiscussion); + const findDiscussionForm = () => wrapper.find(DesignReplyForm); + const findParticipants = () => wrapper.find(Participants); + const findDiscussionsWrapper = () => wrapper.find('.image-notes'); + + function createComponent(loading = false, data = {}, { routeQuery = {} } = {}) { + const $apollo = { + queries: { + design: { + loading, + }, + }, + mutate, + }; + + const $router = { + push: routerPush, + }; + + const $route = { + query: routeQuery, + }; + + wrapper = shallowMount(DesignIndex, { + propsData: { id: '1' }, + mocks: { $apollo, $router, $route }, + stubs: { + ApolloMutation, + DesignDiscussion, + }, + data() { + return { + issueIid: '1', + activeDiscussion: { + id: null, + source: null, + }, + ...data, + }; + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('sets loading state', () => { + createComponent(true); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders design index', () => { + createComponent(false, { design }); + + expect(wrapper.element).toMatchSnapshot(); + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + + it('renders participants', () => { + createComponent(false, { design }); + + expect(findParticipants().exists()).toBe(true); + }); + + it('passes the correct amount of participants to the Participants component', () => { + createComponent(false, { design }); + + expect(findParticipants().props('participants')).toHaveLength(1); + }); + + describe('when has no discussions', () => { + beforeEach(() => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + }); + }); + + it('does not render discussions', () => { + expect(findDiscussions().exists()).toBe(false); + }); + + it('renders a message about possibility to create a new discussion', () => { + expect(wrapper.find('.new-discussion-disclaimer').exists()).toBe(true); + }); + }); + + describe('when has discussions', () => { + beforeEach(() => { + createComponent(false, { design }); + }); + + it('renders correct amount of discussions', () => { + expect(findDiscussions()).toHaveLength(1); + }); + + it('sends a mutation to set an active discussion when clicking on a discussion', () => { + findDiscussions() + .at(0) + .trigger('click'); + + expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables); + }); + + it('sends a mutation to reset an active discussion when clicking outside of discussion', () => { + findDiscussionsWrapper().trigger('click'); + + expect(mutate).toHaveBeenCalledWith({ + ...updateActiveDiscussionMutationVariables, + variables: { id: undefined, source: 'discussion' }, + }); + }); + }); + + it('opens a new discussion form', () => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + }); + + wrapper.vm.openCommentForm({ x: 0, y: 0 }); + + return wrapper.vm.$nextTick().then(() => { + expect(findDiscussionForm().exists()).toBe(true); + }); + }); + + it('sends a mutation on submitting form and closes form', () => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + annotationCoordinates, + comment: newComment, + }); + + findDiscussionForm().vm.$emit('submitForm'); + expect(mutate).toHaveBeenCalledWith(createDiscussionMutationVariables); + + return wrapper.vm + .$nextTick() + .then(() => { + return mutate({ variables: createDiscussionMutationVariables }); + }) + .then(() => { + expect(findDiscussionForm().exists()).toBe(false); + }); + }); + + it('closes the form and clears the comment on canceling form', () => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + annotationCoordinates, + comment: newComment, + }); + + findDiscussionForm().vm.$emit('cancelForm'); + + expect(wrapper.vm.comment).toBe(''); + + return wrapper.vm.$nextTick().then(() => { + expect(findDiscussionForm().exists()).toBe(false); + }); + }); + + describe('with error', () => { + beforeEach(() => { + createComponent(false, { + design: { + ...design, + discussions: { + nodes: [], + }, + }, + errorMessage: 'woops', + }); + }); + + it('GlAlert is rendered in correct position with correct content', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('onDesignQueryResult', () => { + describe('with no designs', () => { + it('redirects to /designs', () => { + createComponent(true); + + wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false }); + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR); + expect(routerPush).toHaveBeenCalledTimes(1); + expect(routerPush).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); + }); + }); + }); + + describe('when no design exists for given version', () => { + it('redirects to /designs', () => { + // attempt to query for a version of the design that doesn't exist + createComponent(true, {}, { routeQuery: { version: '999' } }); + wrapper.setData({ + allVersions: mockAllVersions, + }); + + wrapper.vm.onDesignQueryResult({ data: mockResponseWithDesigns, loading: false }); + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(DESIGN_VERSION_NOT_EXIST_ERROR); + expect(routerPush).toHaveBeenCalledTimes(1); + expect(routerPush).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js new file mode 100644 index 00000000000..2299b858da9 --- /dev/null +++ b/spec/frontend/design_management/pages/index_spec.js @@ -0,0 +1,533 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { ApolloMutation } from 'vue-apollo'; +import VueRouter from 'vue-router'; +import { GlEmptyState } from '@gitlab/ui'; + +import Index from '~/design_management/pages/index.vue'; +import uploadDesignQuery from '~/design_management/graphql/mutations/uploadDesign.mutation.graphql'; +import DesignDestroyer from '~/design_management/components/design_destroyer.vue'; +import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue'; +import DeleteButton from '~/design_management/components/delete_button.vue'; +import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; +import { + EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, + EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, +} from '~/design_management/utils/error_messages'; +import createFlash from '~/flash'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); +const router = new VueRouter({ + routes: [ + { + name: DESIGNS_ROUTE_NAME, + path: '/designs', + component: Index, + }, + ], +}); + +jest.mock('~/flash.js'); + +const mockDesigns = [ + { + id: 'design-1', + image: 'design-1-image', + filename: 'design-1-name', + event: 'NONE', + notesCount: 0, + }, + { + id: 'design-2', + image: 'design-2-image', + filename: 'design-2-name', + event: 'NONE', + notesCount: 1, + }, + { + id: 'design-3', + image: 'design-3-image', + filename: 'design-3-name', + event: 'NONE', + notesCount: 0, + }, +]; + +const mockVersion = { + node: { + id: 'gid://gitlab/DesignManagement::Version/1', + }, +}; + +describe('Design management index page', () => { + let mutate; + let wrapper; + + const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox'); + const findSelectAllButton = () => wrapper.find('.js-select-all'); + const findToolbar = () => wrapper.find('.qa-selector-toolbar'); + const findDeleteButton = () => wrapper.find(DeleteButton); + const findDropzone = () => wrapper.findAll(DesignDropzone).at(0); + const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1); + + function createComponent({ + loading = false, + designs = [], + allVersions = [], + createDesign = true, + stubs = {}, + mockMutate = jest.fn().mockResolvedValue(), + } = {}) { + mutate = mockMutate; + const $apollo = { + queries: { + designs: { + loading, + }, + permissions: { + loading, + }, + }, + mutate, + }; + + wrapper = shallowMount(Index, { + mocks: { $apollo }, + localVue, + router, + stubs: { DesignDestroyer, ApolloMutation, ...stubs }, + attachToDocument: true, + }); + + wrapper.setData({ + designs, + allVersions, + issueIid: '1', + permissions: { + createDesign, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('designs', () => { + it('renders loading icon', () => { + createComponent({ loading: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders error', () => { + createComponent(); + + wrapper.setData({ error: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders a toolbar with buttons when there are designs', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + + return wrapper.vm.$nextTick().then(() => { + expect(findToolbar().exists()).toBe(true); + }); + }); + + it('renders designs list and header with upload button', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('does not render toolbar when there is no permission', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + }); + + describe('when has no designs', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders empty text', () => + wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + })); + + it('does not render a toolbar with buttons', () => + wrapper.vm.$nextTick().then(() => { + expect(findToolbar().exists()).toBe(false); + })); + }); + + describe('uploading designs', () => { + it('calls mutation on upload', () => { + createComponent({ stubs: { GlEmptyState } }); + + const mutationVariables = { + update: expect.anything(), + context: { + hasUpload: true, + }, + mutation: uploadDesignQuery, + variables: { + files: [{ name: 'test' }], + projectPath: '', + iid: '1', + }, + optimisticResponse: { + __typename: 'Mutation', + designManagementUpload: { + __typename: 'DesignManagementUploadPayload', + designs: [ + { + __typename: 'Design', + id: expect.anything(), + image: '', + imageV432x230: '', + filename: 'test', + fullPath: '', + event: 'NONE', + notesCount: 0, + diffRefs: { + __typename: 'DiffRefs', + baseSha: '', + startSha: '', + headSha: '', + }, + discussions: { + __typename: 'DesignDiscussion', + nodes: [], + }, + versions: { + __typename: 'DesignVersionConnection', + edges: { + __typename: 'DesignVersionEdge', + node: { + __typename: 'DesignVersion', + id: expect.anything(), + sha: expect.anything(), + }, + }, + }, + }, + ], + skippedDesigns: [], + errors: [], + }, + }, + }; + + return wrapper.vm.$nextTick().then(() => { + findDropzone().vm.$emit('change', [{ name: 'test' }]); + expect(mutate).toHaveBeenCalledWith(mutationVariables); + expect(wrapper.vm.filesToBeSaved).toEqual([{ name: 'test' }]); + expect(wrapper.vm.isSaving).toBeTruthy(); + }); + }); + + it('sets isSaving', () => { + createComponent(); + + const uploadDesign = wrapper.vm.onUploadDesign([ + { + name: 'test', + }, + ]); + + expect(wrapper.vm.isSaving).toBe(true); + + return uploadDesign.then(() => { + expect(wrapper.vm.isSaving).toBe(false); + }); + }); + + it('updates state appropriately after upload complete', () => { + createComponent({ stubs: { GlEmptyState } }); + wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); + + wrapper.vm.onUploadDesignDone(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.filesToBeSaved).toEqual([]); + expect(wrapper.vm.isSaving).toBeFalsy(); + expect(wrapper.vm.isLatestVersion).toBe(true); + }); + }); + + it('updates state appropriately after upload error', () => { + createComponent({ stubs: { GlEmptyState } }); + wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); + + wrapper.vm.onUploadDesignError(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.filesToBeSaved).toEqual([]); + expect(wrapper.vm.isSaving).toBeFalsy(); + expect(createFlash).toHaveBeenCalled(); + + createFlash.mockReset(); + }); + }); + + it('does not call mutation if createDesign is false', () => { + createComponent({ createDesign: false }); + + wrapper.vm.onUploadDesign([]); + + expect(mutate).not.toHaveBeenCalled(); + }); + + describe('upload count limit', () => { + const MAXIMUM_FILE_UPLOAD_LIMIT = 10; + + afterEach(() => { + createFlash.mockReset(); + }); + + it('does not warn when the max files are uploaded', () => { + createComponent(); + + wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT).fill(mockDesigns[0])); + + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('warns when too many files are uploaded', () => { + createComponent(); + + wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT + 1).fill(mockDesigns[0])); + + expect(createFlash).toHaveBeenCalled(); + }); + }); + + it('flashes warning if designs are skipped', () => { + createComponent({ + mockMutate: () => + Promise.resolve({ + data: { designManagementUpload: { skippedDesigns: [{ filename: 'test.jpg' }] } }, + }), + }); + + const uploadDesign = wrapper.vm.onUploadDesign([ + { + name: 'test', + }, + ]); + + return uploadDesign.then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Upload skipped. test.jpg did not change.', + 'warning', + ); + }); + }); + + describe('dragging onto an existing design', () => { + beforeEach(() => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + }); + + it('calls onUploadDesign with valid upload', () => { + wrapper.setMethods({ + onUploadDesign: jest.fn(), + }); + + const mockUploadPayload = [ + { + name: mockDesigns[0].filename, + }, + ]; + + const designDropzone = findFirstDropzoneWithDesign(); + designDropzone.vm.$emit('change', mockUploadPayload); + + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith(mockUploadPayload); + }); + + it.each` + description | eventPayload | message + ${'> 1 file'} | ${[{ name: 'test' }, { name: 'test-2' }]} | ${EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE} + ${'different filename'} | ${[{ name: 'wrong-name' }]} | ${EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE} + `('calls createFlash when upload has $description', ({ eventPayload, message }) => { + const designDropzone = findFirstDropzoneWithDesign(); + designDropzone.vm.$emit('change', eventPayload); + + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(message); + }); + }); + }); + + describe('on latest version when has designs', () => { + beforeEach(() => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + }); + + it('renders design checkboxes', () => { + expect(findDesignCheckboxes()).toHaveLength(mockDesigns.length); + }); + + it('renders toolbar buttons', () => { + expect(findToolbar().exists()).toBe(true); + expect(findToolbar().classes()).toContain('d-flex'); + expect(findToolbar().classes()).not.toContain('d-none'); + }); + + it('adds two designs to selected designs when their checkboxes are checked', () => { + findDesignCheckboxes() + .at(0) + .trigger('click'); + + return wrapper.vm + .$nextTick() + .then(() => { + findDesignCheckboxes() + .at(1) + .trigger('click'); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findDeleteButton().exists()).toBe(true); + expect(findSelectAllButton().text()).toBe('Deselect all'); + findDeleteButton().vm.$emit('deleteSelectedDesigns'); + const [{ variables }] = mutate.mock.calls[0]; + expect(variables.filenames).toStrictEqual([ + mockDesigns[0].filename, + mockDesigns[1].filename, + ]); + }); + }); + + it('adds all designs to selected designs when Select All button is clicked', () => { + findSelectAllButton().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findDeleteButton().props().hasSelectedDesigns).toBe(true); + expect(findSelectAllButton().text()).toBe('Deselect all'); + expect(wrapper.vm.selectedDesigns).toEqual(mockDesigns.map(design => design.filename)); + }); + }); + + it('removes all designs from selected designs when at least one design was selected', () => { + findDesignCheckboxes() + .at(0) + .trigger('click'); + + return wrapper.vm + .$nextTick() + .then(() => { + findSelectAllButton().vm.$emit('click'); + }) + .then(() => { + expect(findDeleteButton().props().hasSelectedDesigns).toBe(false); + expect(findSelectAllButton().text()).toBe('Select all'); + expect(wrapper.vm.selectedDesigns).toEqual([]); + }); + }); + }); + + it('on latest version when has no designs does not render toolbar buttons', () => { + createComponent({ designs: [], allVersions: [mockVersion] }); + expect(findToolbar().exists()).toBe(false); + }); + + describe('on non-latest version', () => { + beforeEach(() => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + + router.replace({ + name: DESIGNS_ROUTE_NAME, + query: { + version: '2', + }, + }); + }); + + it('does not render design checkboxes', () => { + expect(findDesignCheckboxes()).toHaveLength(0); + }); + + it('does not render Delete selected button', () => { + expect(findDeleteButton().exists()).toBe(false); + }); + + it('does not render Select All button', () => { + expect(findSelectAllButton().exists()).toBe(false); + }); + }); + + describe('pasting a design', () => { + let event; + beforeEach(() => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + + wrapper.setMethods({ + onUploadDesign: jest.fn(), + }); + + event = new Event('paste'); + + router.replace({ + name: DESIGNS_ROUTE_NAME, + query: { + version: '2', + }, + }); + }); + + it('calls onUploadDesign with valid paste', () => { + event.clipboardData = { + files: [{ name: 'image.png', type: 'image/png' }], + getData: () => 'test.png', + }; + + document.dispatchEvent(event); + + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([ + new File([{ name: 'image.png' }], 'test.png'), + ]); + }); + + it('renames a design if it has an image.png filename', () => { + event.clipboardData = { + files: [{ name: 'image.png', type: 'image/png' }], + getData: () => 'image.png', + }; + + document.dispatchEvent(event); + + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([ + new File([{ name: 'image.png' }], `design_${Date.now()}.png`), + ]); + }); + + it('does not call onUploadDesign with invalid paste', () => { + event.clipboardData = { + items: [{ type: 'text/plain' }, { type: 'text' }], + files: [], + }; + + document.dispatchEvent(event); + + expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js new file mode 100644 index 00000000000..0f4afa5e288 --- /dev/null +++ b/spec/frontend/design_management/router_spec.js @@ -0,0 +1,81 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueRouter from 'vue-router'; +import App from '~/design_management/components/app.vue'; +import Designs from '~/design_management/pages/index.vue'; +import DesignDetail from '~/design_management/pages/design/index.vue'; +import createRouter from '~/design_management/router'; +import { + ROOT_ROUTE_NAME, + DESIGNS_ROUTE_NAME, + DESIGN_ROUTE_NAME, +} from '~/design_management/router/constants'; +import '~/commons/bootstrap'; + +function factory(routeArg) { + const localVue = createLocalVue(); + localVue.use(VueRouter); + + window.gon = { sprite_icons: '' }; + + const router = createRouter('/'); + if (routeArg !== undefined) { + router.push(routeArg); + } + + return mount(App, { + localVue, + router, + mocks: { + $apollo: { + queries: { + designs: { loading: true }, + design: { loading: true }, + permissions: { loading: true }, + }, + }, + }, + }); +} + +jest.mock('mousetrap', () => ({ + bind: jest.fn(), + unbind: jest.fn(), +})); + +describe('Design management router', () => { + afterEach(() => { + window.location.hash = ''; + }); + + describe.each([['/'], [{ name: ROOT_ROUTE_NAME }]])('root route', routeArg => { + it('pushes home component', () => { + const wrapper = factory(routeArg); + + expect(wrapper.find(Designs).exists()).toBe(true); + }); + }); + + describe.each([['/designs'], [{ name: DESIGNS_ROUTE_NAME }]])('designs route', routeArg => { + it('pushes designs root component', () => { + const wrapper = factory(routeArg); + + expect(wrapper.find(Designs).exists()).toBe(true); + }); + }); + + describe.each([['/designs/1'], [{ name: DESIGN_ROUTE_NAME, params: { id: '1' } }]])( + 'designs detail route', + routeArg => { + it('pushes designs detail component', () => { + const wrapper = factory(routeArg); + + return nextTick().then(() => { + const detail = wrapper.find(DesignDetail); + expect(detail.exists()).toBe(true); + expect(detail.props('id')).toEqual('1'); + }); + }); + }, + ); +}); diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js new file mode 100644 index 00000000000..641d35ff9ff --- /dev/null +++ b/spec/frontend/design_management/utils/cache_update_spec.js @@ -0,0 +1,44 @@ +import { InMemoryCache } from 'apollo-cache-inmemory'; +import { + updateStoreAfterDesignsDelete, + updateStoreAfterAddDiscussionComment, + updateStoreAfterAddImageDiffNote, + updateStoreAfterUploadDesign, + updateStoreAfterUpdateImageDiffNote, +} from '~/design_management/utils/cache_update'; +import { + designDeletionError, + ADD_DISCUSSION_COMMENT_ERROR, + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, +} from '~/design_management/utils/error_messages'; +import design from '../mock_data/design'; +import createFlash from '~/flash'; + +jest.mock('~/flash.js'); + +describe('Design Management cache update', () => { + const mockErrors = ['code red!']; + + let mockStore; + + beforeEach(() => { + mockStore = new InMemoryCache(); + }); + + describe('error handling', () => { + it.each` + fnName | subject | errorMessage | extraArgs + ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]} + ${'updateStoreAfterAddDiscussionComment'} | ${updateStoreAfterAddDiscussionComment} | ${ADD_DISCUSSION_COMMENT_ERROR} | ${[]} + ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} + ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} + ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} + `('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => { + expect(createFlash).not.toHaveBeenCalled(); + expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow(); + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(errorMessage); + }); + }); +}); diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js new file mode 100644 index 00000000000..af631073df6 --- /dev/null +++ b/spec/frontend/design_management/utils/design_management_utils_spec.js @@ -0,0 +1,176 @@ +import { + extractCurrentDiscussion, + extractDiscussions, + findVersionId, + designUploadOptimisticResponse, + updateImageDiffNoteOptimisticResponse, + isValidDesignFile, + extractDesign, +} from '~/design_management/utils/design_management_utils'; +import mockResponseNoDesigns from '../mock_data/no_designs'; +import mockResponseWithDesigns from '../mock_data/designs'; +import mockDesign from '../mock_data/design'; + +jest.mock('lodash/uniqueId', () => () => 1); + +describe('extractCurrentDiscussion', () => { + let discussions; + + beforeEach(() => { + discussions = { + nodes: [ + { id: 101, payload: 'w' }, + { id: 102, payload: 'x' }, + { id: 103, payload: 'y' }, + { id: 104, payload: 'z' }, + ], + }; + }); + + it('finds the relevant discussion if it exists', () => { + const id = 103; + expect(extractCurrentDiscussion(discussions, id)).toEqual({ id, payload: 'y' }); + }); + + it('returns null if the relevant discussion does not exist', () => { + expect(extractCurrentDiscussion(discussions, 0)).not.toBeDefined(); + }); +}); + +describe('extractDiscussions', () => { + let discussions; + + beforeEach(() => { + discussions = { + nodes: [ + { id: 1, notes: { nodes: ['a'] } }, + { id: 2, notes: { nodes: ['b'] } }, + { id: 3, notes: { nodes: ['c'] } }, + { id: 4, notes: { nodes: ['d'] } }, + ], + }; + }); + + it('discards the edges.node artifacts of GraphQL', () => { + expect(extractDiscussions(discussions)).toEqual([ + { id: 1, notes: ['a'] }, + { id: 2, notes: ['b'] }, + { id: 3, notes: ['c'] }, + { id: 4, notes: ['d'] }, + ]); + }); +}); + +describe('version parser', () => { + it('correctly extracts version ID from a valid version string', () => { + const testVersionId = '123'; + const testVersionString = `gid://gitlab/DesignManagement::Version/${testVersionId}`; + + expect(findVersionId(testVersionString)).toEqual(testVersionId); + }); + + it('fails to extract version ID from an invalid version string', () => { + const testInvalidVersionString = `gid://gitlab/DesignManagement::Version`; + + expect(findVersionId(testInvalidVersionString)).toBeUndefined(); + }); +}); + +describe('optimistic responses', () => { + it('correctly generated for designManagementUpload', () => { + const expectedResponse = { + __typename: 'Mutation', + designManagementUpload: { + __typename: 'DesignManagementUploadPayload', + designs: [ + { + __typename: 'Design', + id: -1, + image: '', + imageV432x230: '', + filename: 'test', + fullPath: '', + notesCount: 0, + event: 'NONE', + diffRefs: { __typename: 'DiffRefs', baseSha: '', startSha: '', headSha: '' }, + discussions: { __typename: 'DesignDiscussion', nodes: [] }, + versions: { + __typename: 'DesignVersionConnection', + edges: { + __typename: 'DesignVersionEdge', + node: { __typename: 'DesignVersion', id: -1, sha: -1 }, + }, + }, + }, + ], + errors: [], + skippedDesigns: [], + }, + }; + expect(designUploadOptimisticResponse([{ name: 'test' }])).toEqual(expectedResponse); + }); + + it('correctly generated for updateImageDiffNoteOptimisticResponse', () => { + const mockNote = { + id: 'test-note-id', + }; + + const mockPosition = { + x: 10, + y: 10, + width: 10, + height: 10, + }; + + const expectedResponse = { + __typename: 'Mutation', + updateImageDiffNote: { + __typename: 'UpdateImageDiffNotePayload', + note: { + ...mockNote, + position: mockPosition, + }, + errors: [], + }, + }; + expect(updateImageDiffNoteOptimisticResponse(mockNote, { position: mockPosition })).toEqual( + expectedResponse, + ); + }); +}); + +describe('isValidDesignFile', () => { + // test every filetype that Design Management supports + // https://docs.gitlab.com/ee/user/project/issues/design_management.html#limitations + it.each` + mimetype | isValid + ${'image/svg'} | ${true} + ${'image/png'} | ${true} + ${'image/jpg'} | ${true} + ${'image/jpeg'} | ${true} + ${'image/gif'} | ${true} + ${'image/bmp'} | ${true} + ${'image/tiff'} | ${true} + ${'image/ico'} | ${true} + ${'image/svg'} | ${true} + ${'video/mpeg'} | ${false} + ${'audio/midi'} | ${false} + ${'application/octet-stream'} | ${false} + `('returns $isValid for file type $mimetype', ({ mimetype, isValid }) => { + expect(isValidDesignFile({ type: mimetype })).toBe(isValid); + }); +}); + +describe('extractDesign', () => { + describe('with no designs', () => { + it('returns undefined', () => { + expect(extractDesign(mockResponseNoDesigns)).toBeUndefined(); + }); + }); + + describe('with designs', () => { + it('returns the first design available', () => { + expect(extractDesign(mockResponseWithDesigns)).toEqual(mockDesign); + }); + }); +}); diff --git a/spec/frontend/design_management/utils/error_messages_spec.js b/spec/frontend/design_management/utils/error_messages_spec.js new file mode 100644 index 00000000000..635ff931d7d --- /dev/null +++ b/spec/frontend/design_management/utils/error_messages_spec.js @@ -0,0 +1,62 @@ +import { + designDeletionError, + designUploadSkippedWarning, +} from '~/design_management/utils/error_messages'; + +const mockFilenames = n => + Array(n) + .fill(0) + .map((_, i) => ({ filename: `${i + 1}.jpg` })); + +describe('Error message', () => { + describe('designDeletionError', () => { + const singularMsg = 'Could not delete a design. Please try again.'; + const pluralMsg = 'Could not delete designs. Please try again.'; + + describe('when [singular=true]', () => { + it.each([[undefined], [true]])('uses singular grammar', singularOption => { + expect(designDeletionError({ singular: singularOption })).toEqual(singularMsg); + }); + }); + + describe('when [singular=false]', () => { + it('uses plural grammar', () => { + expect(designDeletionError({ singular: false })).toEqual(pluralMsg); + }); + }); + }); + + describe.each([ + [[], [], null], + [mockFilenames(1), mockFilenames(1), 'Upload skipped. 1.jpg did not change.'], + [ + mockFilenames(2), + mockFilenames(2), + 'Upload skipped. The designs you tried uploading did not change.', + ], + [ + mockFilenames(2), + mockFilenames(1), + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg.', + ], + [ + mockFilenames(6), + mockFilenames(5), + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg.', + ], + [ + mockFilenames(7), + mockFilenames(6), + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 1 more.', + ], + [ + mockFilenames(8), + mockFilenames(7), + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 2 more.', + ], + ])('designUploadSkippedWarning', (uploadedFiles, skippedFiles, expected) => { + test('returns expected warning message', () => { + expect(designUploadSkippedWarning(uploadedFiles, skippedFiles)).toBe(expected); + }); + }); +}); diff --git a/spec/frontend/design_management/utils/tracking_spec.js b/spec/frontend/design_management/utils/tracking_spec.js new file mode 100644 index 00000000000..9fa5eae55b3 --- /dev/null +++ b/spec/frontend/design_management/utils/tracking_spec.js @@ -0,0 +1,53 @@ +import { mockTracking } from 'helpers/tracking_helper'; +import { trackDesignDetailView } from '~/design_management/utils/tracking'; + +function getTrackingSpy(key) { + return mockTracking(key, undefined, jest.spyOn); +} + +describe('Tracking Events', () => { + describe('trackDesignDetailView', () => { + const eventKey = 'projects:issues:design'; + const eventName = 'design_viewed'; + + it('trackDesignDetailView fires a tracking event when called', () => { + const trackingSpy = getTrackingSpy(eventKey); + + trackDesignDetailView(); + + expect(trackingSpy).toHaveBeenCalledWith( + eventKey, + eventName, + expect.objectContaining({ + label: eventName, + value: { + 'internal-object-refrerer': '', + 'design-collection-owner': '', + 'design-version-number': 1, + 'design-is-current-version': false, + }, + }), + ); + }); + + it('trackDesignDetailView allows to customize the value payload', () => { + const trackingSpy = getTrackingSpy(eventKey); + + trackDesignDetailView('from-a-test', 'test', 100, true); + + expect(trackingSpy).toHaveBeenCalledWith( + eventKey, + eventName, + expect.objectContaining({ + label: eventName, + value: { + 'internal-object-refrerer': 'from-a-test', + 'design-collection-owner': 'test', + 'design-version-number': 100, + 'design-is-current-version': true, + }, + }), + ); + }); + }); +}); |