diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-07 15:09:46 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-07 15:09:46 +0300 |
commit | 896b68514b43b9646d763e67f63fbe8f9ef2f723 (patch) | |
tree | b0b21f37cbbc809782532e6d6180677c7c4c9e30 /spec/frontend | |
parent | d8803c7e40bd35d883ef007ddc56907bd837a748 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend')
55 files changed, 7653 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..c6d8f9fe174 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap @@ -0,0 +1,66 @@ +// 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_undefined" +> + <user-avatar-link-stub + imgalt="" + imgcssclasses="" + imgsize="40" + imgsrc="" + linkhref="" + tooltipplacement="top" + tooltiptext="" + username="" + /> + + <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 + class="system-note-separator" + /> + + <a + class="note-timestamp system-note-separator" + href="#note_undefined" + > + <time-ago-tooltip-stub + cssclass="" + time="2019-07-26T15:02:20Z" + tooltipplacement="bottom" + /> + </a> + </span> + + <div + class="note-text md" + data-qa-selector="note_content" + /> +</timeline-entry-item-stub> +`; 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..dd2ca29d660 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -0,0 +1,121 @@ +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 = {}) { + wrapper = shallowMount(DesignDiscussion, { + propsData: { + discussion: { + id: '0', + notes: [ + { + id: '1', + }, + { + id: '2', + }, + ], + }, + noteableId: 'noteable-id', + designId: 'design-id', + discussionIndex: 1, + ...props, + }, + stubs: { + ReplyPlaceholder, + ApolloMutation, + }, + mocks: { $apollo }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders correct amount of discussion notes', () => { + expect(wrapper.findAll(DesignNote)).toHaveLength(2); + }); + + it('renders reply placeholder by default', () => { + expect(findReplyPlaceholder().exists()).toBe(true); + }); + + it('hides reply placeholder and opens form on placeholder click', () => { + 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', () => { + wrapper.setData({ + discussionComment: 'test', + isFormRendered: true, + }); + + return wrapper.vm + .$nextTick() + .then(() => { + findReplyForm().vm.$emit('submitForm'); + + expect(mutate).toHaveBeenCalledWith(mutationVariables); + + return mutate({ variables: mutationVariables }); + }) + .then(() => { + expect(findReplyForm().exists()).toBe(false); + }); + }); + + it('clears the discussion comment on closing comment form', () => { + wrapper.setData({ + 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); + }); + }); +}); 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..4e5b7a66611 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -0,0 +1,89 @@ +import { shallowMount } from '@vue/test-utils'; +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'; + +const scrollIntoViewMock = jest.fn(); +HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; + +const $route = { + hash: '#note_123', +}; + +describe('Design note component', () => { + let wrapper; + + const findUserAvatar = () => wrapper.find(UserAvatarLink); + const findUserLink = () => wrapper.find('.js-user-link'); + + function createComponent(props = {}) { + wrapper = shallowMount(DesignNote, { + propsData: { + note: {}, + ...props, + }, + mocks: { + $route, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('should match the snapshot', () => { + createComponent({ + note: { + id: '1', + createdAt: '2019-07-26T15:02:20Z', + author: { + id: 'author-id', + }, + }, + }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should render an author', () => { + createComponent({ + note: { + id: '1', + author: { + id: 'author-id', + }, + }, + }); + + expect(findUserAvatar().exists()).toBe(true); + expect(findUserLink().exists()).toBe(true); + }); + + it('should render a time ago tooltip if note has createdAt property', () => { + createComponent({ + note: { + id: '1', + createdAt: '2019-07-26T15:02:20Z', + author: { + id: 'author-id', + }, + }, + }); + + expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); + }); + + it('should trigger a scrollIntoView method', () => { + createComponent({ + note: { + id: 'gid://gitlab/DiffNote/123', + author: { + id: 'author-id', + }, + }, + }); + + expect(scrollIntoViewMock).toHaveBeenCalled(); + }); +}); 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..0780a9017f4 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js @@ -0,0 +1,142 @@ +import { mount } from '@vue/test-utils'; +import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; + +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, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('textarea has focus after component mount', () => { + createComponent(); + + expect(findTextarea().element).toEqual(document.activeElement); + }); + + 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('opens confirmation modal on pressing Escape button', () => { + findTextarea().trigger('keyup.esc'); + + expect(findModal().exists()).toBe(true); + }); + + it('opens confirmation modal on Cancel button click', () => { + findCancelButton().vm.$emit('click'); + + expect(findModal().exists()).toBe(true); + }); + + 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..df470819a1d --- /dev/null +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -0,0 +1,343 @@ +import { mount } from '@vue/test-utils'; +import DesignOverlay from '~/design_management/components/design_overlay.vue'; +import notes from '../mock_data/notes'; + +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 = {}) { + wrapper = mount(DesignOverlay, { + propsData: { + dimensions: mockDimensions, + position: { + top: '0', + left: '0', + }, + ...props, + }, + }); + } + + 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;'); + }); + }); + }); + + 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..444e44f289f --- /dev/null +++ b/spec/frontend/design_management/mock_data/design.js @@ -0,0 +1,56 @@ +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: { + edges: [ + { + node: { + 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..3f559590e94 --- /dev/null +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -0,0 +1,161 @@ +// 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]" + /> + + <design-discussion-stub + designid="1" + discussion="[object Object]" + discussionindex="1" + markdownpreviewpath="//preview_markdown?target_type=Issue" + noteableid="design-id" + /> + + <!----> + </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..e272c1d3374 --- /dev/null +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -0,0 +1,298 @@ +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 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 mutationVariables = { + 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 mutate = jest.fn().mockResolvedValue(); + const routerPush = jest.fn(); + + const findDiscussions = () => wrapper.findAll(DesignDiscussion); + const findDiscussionForm = () => wrapper.find(DesignReplyForm); + const findParticipants = () => wrapper.find(Participants); + + function createComponent(loading = false, { 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, + }, + }); + + wrapper.setData({ + issueIid: '1', + }); + } + + function setDesign() { + createComponent(true); + wrapper.vm.$apollo.queries.design.loading = false; + } + + function setDesignData() { + wrapper.setData({ + design, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('sets loading state', () => { + createComponent(true); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders design index', () => { + setDesign(); + setDesignData(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + }); + + it('renders participants', () => { + setDesign(); + setDesignData(); + + return wrapper.vm.$nextTick().then(() => { + expect(findParticipants().exists()).toBe(true); + }); + }); + + it('passes the correct amount of participants to the Participants component', () => { + expect(findParticipants().props('participants')).toHaveLength(1); + }); + + describe('when has no discussions', () => { + beforeEach(() => { + setDesign(); + + wrapper.setData({ + 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(() => { + setDesign(); + setDesignData(); + }); + + it('renders correct amount of discussions', () => { + expect(findDiscussions()).toHaveLength(1); + }); + }); + + it('opens a new discussion form', () => { + setDesign(); + + wrapper.setData({ + 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', () => { + setDesign(); + + wrapper.setData({ + design: { + ...design, + discussions: { + nodes: [], + }, + }, + annotationCoordinates, + comment: newComment, + }); + + return wrapper.vm + .$nextTick() + .then(() => { + findDiscussionForm().vm.$emit('submitForm'); + + expect(mutate).toHaveBeenCalledWith(mutationVariables); + return mutate({ variables: mutationVariables }); + }) + .then(() => { + expect(findDiscussionForm().exists()).toBe(false); + }); + }); + + it('closes the form and clears the comment on canceling form', () => { + setDesign(); + + wrapper.setData({ + design: { + ...design, + discussions: { + nodes: [], + }, + }, + annotationCoordinates, + comment: newComment, + }); + + return wrapper.vm + .$nextTick() + .then(() => { + findDiscussionForm().vm.$emit('cancelForm'); + + expect(wrapper.vm.comment).toBe(''); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(findDiscussionForm().exists()).toBe(false); + }); + }); + + describe('with error', () => { + beforeEach(() => { + setDesign(); + + wrapper.setData({ + 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..fc88bfa06d2 --- /dev/null +++ b/spec/frontend/design_management/router_spec.js @@ -0,0 +1,88 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +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'; + +jest.mock('mousetrap', () => ({ + bind: jest.fn(), + unbind: jest.fn(), +})); + +describe('Design management router', () => { + let vm; + let router; + + function factory() { + const localVue = createLocalVue(); + + localVue.use(VueRouter); + + window.gon = { sprite_icons: '' }; + + router = createRouter('/'); + + vm = mount(App, { + localVue, + router, + mocks: { + $apollo: { + queries: { + designs: { loading: true }, + design: { loading: true }, + permissions: { loading: true }, + }, + }, + }, + }); + } + + beforeEach(() => { + factory(); + }); + + afterEach(() => { + vm.destroy(); + + router.app.$destroy(); + window.location.hash = ''; + }); + + describe.each([['/'], [{ name: ROOT_ROUTE_NAME }]])('root route', pushArg => { + it('pushes home component', () => { + router.push(pushArg); + + expect(vm.find(Designs).exists()).toBe(true); + }); + }); + + describe.each([['/designs'], [{ name: DESIGNS_ROUTE_NAME }]])('designs route', pushArg => { + it('pushes designs root component', () => { + router.push(pushArg); + + expect(vm.find(Designs).exists()).toBe(true); + }); + }); + + describe.each([['/designs/1'], [{ name: DESIGN_ROUTE_NAME, params: { id: '1' } }]])( + 'designs detail route', + pushArg => { + it('pushes designs detail component', () => { + router.push(pushArg); + + return vm.vm.$nextTick().then(() => { + const detail = vm.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/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js new file mode 100644 index 00000000000..3320b6b0942 --- /dev/null +++ b/spec/frontend/filtered_search/dropdown_utils_spec.js @@ -0,0 +1,374 @@ +import DropdownUtils from '~/filtered_search/dropdown_utils'; +import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; + +describe('Dropdown Utils', () => { + const issueListFixture = 'issues/issue_list.html'; + preloadFixtures(issueListFixture); + + describe('getEscapedText', () => { + it('should return same word when it has no space', () => { + const escaped = DropdownUtils.getEscapedText('textWithoutSpace'); + + expect(escaped).toBe('textWithoutSpace'); + }); + + it('should escape with double quotes', () => { + let escaped = DropdownUtils.getEscapedText('text with space'); + + expect(escaped).toBe('"text with space"'); + + escaped = DropdownUtils.getEscapedText("won't fix"); + + expect(escaped).toBe('"won\'t fix"'); + }); + + it('should escape with single quotes', () => { + const escaped = DropdownUtils.getEscapedText('won"t fix'); + + expect(escaped).toBe("'won\"t fix'"); + }); + + it('should escape with single quotes by default', () => { + const escaped = DropdownUtils.getEscapedText('won"t\' fix'); + + expect(escaped).toBe("'won\"t' fix'"); + }); + }); + + describe('filterWithSymbol', () => { + let input; + const item = { + title: '@root', + }; + + beforeEach(() => { + setFixtures(` + <input type="text" id="test" /> + `); + + input = document.getElementById('test'); + }); + + it('should filter without symbol', () => { + input.value = 'roo'; + + const updatedItem = DropdownUtils.filterWithSymbol('@', input, item); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with symbol', () => { + input.value = '@roo'; + + const updatedItem = DropdownUtils.filterWithSymbol('@', input, item); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + describe('filters multiple word title', () => { + const multipleWordItem = { + title: 'Community Contributions', + }; + + it('should filter with double quote', () => { + input.value = '"'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with double quote and symbol', () => { + input.value = '~"'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with double quote and multiple words', () => { + input.value = '"community con'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with double quote, symbol and multiple words', () => { + input.value = '~"community con'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote', () => { + input.value = "'"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote and symbol', () => { + input.value = "~'"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote and multiple words', () => { + input.value = "'community con"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote, symbol and multiple words', () => { + input.value = "~'community con"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + }); + }); + + describe('filterHint', () => { + let input; + let allowedKeys; + + beforeEach(() => { + setFixtures(` + <ul class="tokens-container"> + <li class="input-token"> + <input class="filtered-search" type="text" id="test" /> + </li> + </ul> + `); + + input = document.getElementById('test'); + allowedKeys = IssuableFilteredSearchTokenKeys.getKeys(); + }); + + function config() { + return { + input, + allowedKeys, + }; + } + + it('should filter', () => { + input.value = 'l'; + let updatedItem = DropdownUtils.filterHint(config(), { + hint: 'label', + }); + + expect(updatedItem.droplab_hidden).toBe(false); + + input.value = 'o'; + updatedItem = DropdownUtils.filterHint(config(), { + hint: 'label', + }); + + expect(updatedItem.droplab_hidden).toBe(true); + }); + + it('should return droplab_hidden false when item has no hint', () => { + const updatedItem = DropdownUtils.filterHint(config(), {}, ''); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should allow multiple if item.type is array', () => { + input.value = 'label:~first la'; + const updatedItem = DropdownUtils.filterHint(config(), { + hint: 'label', + type: 'array', + }); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should prevent multiple if item.type is not array', () => { + input.value = 'milestone:~first mile'; + let updatedItem = DropdownUtils.filterHint(config(), { + hint: 'milestone', + }); + + expect(updatedItem.droplab_hidden).toBe(true); + + updatedItem = DropdownUtils.filterHint(config(), { + hint: 'milestone', + type: 'string', + }); + + expect(updatedItem.droplab_hidden).toBe(true); + }); + }); + + describe('setDataValueIfSelected', () => { + beforeEach(() => { + jest.spyOn(FilteredSearchDropdownManager, 'addWordToInput').mockImplementation(() => {}); + }); + + it('calls addWordToInput when dataValue exists', () => { + const selected = { + getAttribute: () => 'value', + hasAttribute: () => false, + }; + + DropdownUtils.setDataValueIfSelected(null, '=', selected); + + expect(FilteredSearchDropdownManager.addWordToInput.mock.calls.length).toEqual(1); + }); + + it('returns true when dataValue exists', () => { + const selected = { + getAttribute: () => 'value', + hasAttribute: () => false, + }; + + const result = DropdownUtils.setDataValueIfSelected(null, '=', selected); + const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected); + + expect(result).toBe(true); + expect(result2).toBe(true); + }); + + it('returns false when dataValue does not exist', () => { + const selected = { + getAttribute: () => null, + }; + + const result = DropdownUtils.setDataValueIfSelected(null, '=', selected); + const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected); + + expect(result).toBe(false); + expect(result2).toBe(false); + }); + }); + + describe('getInputSelectionPosition', () => { + describe('word with trailing spaces', () => { + const value = 'label:none '; + + it('should return selectionStart when cursor is at the trailing space', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 11, + value, + }); + + expect(left).toBe(11); + expect(right).toBe(11); + }); + + it('should return input when cursor is at the start of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 0, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(10); + }); + + it('should return input when cursor is at the middle of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 7, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(10); + }); + + it('should return input when cursor is at the end of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 10, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(10); + }); + }); + + describe('multiple words', () => { + const value = 'label:~"Community Contribution"'; + + it('should return input when cursor is after the first word', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 17, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(31); + }); + + it('should return input when cursor is before the second word', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 18, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(31); + }); + }); + + describe('incomplete multiple words', () => { + const value = 'label:~"Community Contribution'; + + it('should return entire input when cursor is at the start of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 0, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(30); + }); + + it('should return entire input when cursor is at the end of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 30, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(30); + }); + }); + }); + + describe('getSearchQuery', () => { + let authorToken; + + beforeEach(() => { + loadFixtures(issueListFixture); + + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); + const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term'); + + const tokensContainer = document.querySelector('.tokens-container'); + tokensContainer.appendChild(searchTermToken); + tokensContainer.appendChild(authorToken); + }); + + it('uses original value if present', () => { + const originalValue = 'original dance'; + const valueContainer = authorToken.querySelector('.value-container'); + valueContainer.dataset.originalValue = originalValue; + + const searchQuery = DropdownUtils.getSearchQuery(); + + expect(searchQuery).toBe(' search term author:=original dance'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js b/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js new file mode 100644 index 00000000000..dec03e5ab93 --- /dev/null +++ b/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js @@ -0,0 +1,152 @@ +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer'; + +describe('Filtered Search Tokenizer', () => { + const allowedKeys = IssuableFilteredSearchTokenKeys.getKeys(); + + describe('processTokens', () => { + it('returns for input containing only search value', () => { + const results = FilteredSearchTokenizer.processTokens('searchTerm', allowedKeys); + + expect(results.searchToken).toBe('searchTerm'); + expect(results.tokens.length).toBe(0); + expect(results.lastToken).toBe(results.searchToken); + }); + + it('returns for input containing only tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'author:@root label:~"Very Important" milestone:%v1.0 assignee:none', + allowedKeys, + ); + + expect(results.searchToken).toBe(''); + expect(results.tokens.length).toBe(4); + expect(results.tokens[3]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('label'); + expect(results.tokens[1].value).toBe('"Very Important"'); + expect(results.tokens[1].symbol).toBe('~'); + + expect(results.tokens[2].key).toBe('milestone'); + expect(results.tokens[2].value).toBe('v1.0'); + expect(results.tokens[2].symbol).toBe('%'); + + expect(results.tokens[3].key).toBe('assignee'); + expect(results.tokens[3].value).toBe('none'); + expect(results.tokens[3].symbol).toBe(''); + }); + + it('returns for input starting with search value and ending with tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'searchTerm anotherSearchTerm milestone:none', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(1); + expect(results.tokens[0]).toBe(results.lastToken); + expect(results.tokens[0].key).toBe('milestone'); + expect(results.tokens[0].value).toBe('none'); + expect(results.tokens[0].symbol).toBe(''); + }); + + it('returns for input starting with tokens and ending with search value', () => { + const results = FilteredSearchTokenizer.processTokens( + 'assignee:@user searchTerm', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm'); + expect(results.tokens.length).toBe(1); + expect(results.tokens[0].key).toBe('assignee'); + expect(results.tokens[0].value).toBe('user'); + expect(results.tokens[0].symbol).toBe('@'); + expect(results.lastToken).toBe(results.searchToken); + }); + + it('returns for input containing search value wrapped between tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(3); + expect(results.tokens[2]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('label'); + expect(results.tokens[1].value).toBe('"Won\'t fix"'); + expect(results.tokens[1].symbol).toBe('~'); + + expect(results.tokens[2].key).toBe('milestone'); + expect(results.tokens[2].value).toBe('none'); + expect(results.tokens[2].symbol).toBe(''); + }); + + it('returns for input containing search value in between tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'author:@root searchTerm assignee:none anotherSearchTerm label:~Doing', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(3); + expect(results.tokens[2]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('assignee'); + expect(results.tokens[1].value).toBe('none'); + expect(results.tokens[1].symbol).toBe(''); + + expect(results.tokens[2].key).toBe('label'); + expect(results.tokens[2].value).toBe('Doing'); + expect(results.tokens[2].symbol).toBe('~'); + }); + + it('returns search value for invalid tokens', () => { + const results = FilteredSearchTokenizer.processTokens('fake:token', allowedKeys); + + expect(results.lastToken).toBe('fake:token'); + expect(results.searchToken).toBe('fake:token'); + expect(results.tokens.length).toEqual(0); + }); + + it('returns search value and token for mix of valid and invalid tokens', () => { + const results = FilteredSearchTokenizer.processTokens('label:real fake:token', allowedKeys); + + expect(results.tokens.length).toEqual(1); + expect(results.tokens[0].key).toBe('label'); + expect(results.tokens[0].value).toBe('real'); + expect(results.tokens[0].symbol).toBe(''); + expect(results.lastToken).toBe('fake:token'); + expect(results.searchToken).toBe('fake:token'); + }); + + it('returns search value for invalid symbols', () => { + const results = FilteredSearchTokenizer.processTokens('std::includes', allowedKeys); + + expect(results.lastToken).toBe('std::includes'); + expect(results.searchToken).toBe('std::includes'); + }); + + it('removes duplicated values', () => { + const results = FilteredSearchTokenizer.processTokens('label:~foo label:~foo', allowedKeys); + + expect(results.tokens.length).toBe(1); + expect(results.tokens[0].key).toBe('label'); + expect(results.tokens[0].value).toBe('foo'); + expect(results.tokens[0].symbol).toBe('~'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js new file mode 100644 index 00000000000..c7be900ba2c --- /dev/null +++ b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js @@ -0,0 +1,148 @@ +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; + +describe('Issues Filtered Search Token Keys', () => { + describe('get', () => { + let tokenKeys; + + beforeEach(() => { + tokenKeys = IssuableFilteredSearchTokenKeys.get(); + }); + + it('should return tokenKeys', () => { + expect(tokenKeys).not.toBeNull(); + }); + + it('should return tokenKeys as an array', () => { + expect(tokenKeys instanceof Array).toBe(true); + }); + + it('should always return the same array', () => { + const tokenKeys2 = IssuableFilteredSearchTokenKeys.get(); + + expect(tokenKeys).toEqual(tokenKeys2); + }); + + it('should return assignee as a string', () => { + const assignee = tokenKeys.find(tokenKey => tokenKey.key === 'assignee'); + + expect(assignee.type).toEqual('string'); + }); + }); + + describe('getKeys', () => { + it('should return keys', () => { + const getKeys = IssuableFilteredSearchTokenKeys.getKeys(); + const keys = IssuableFilteredSearchTokenKeys.get().map(i => i.key); + + keys.forEach((key, i) => { + expect(key).toEqual(getKeys[i]); + }); + }); + }); + + describe('getConditions', () => { + let conditions; + + beforeEach(() => { + conditions = IssuableFilteredSearchTokenKeys.getConditions(); + }); + + it('should return conditions', () => { + expect(conditions).not.toBeNull(); + }); + + it('should return conditions as an array', () => { + expect(conditions instanceof Array).toBe(true); + }); + }); + + describe('searchByKey', () => { + it('should return null when key not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchByKey('notakey'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by key', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchByKey(tokenKeys[0].key); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchBySymbol', () => { + it('should return null when symbol not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchBySymbol('notasymbol'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by symbol', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByKeyParam', () => { + it('should return null when key param not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchByKeyParam('notakeyparam'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by key param', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchByKeyParam( + `${tokenKeys[0].key}_${tokenKeys[0].param}`, + ); + + expect(result).toEqual(tokenKeys[0]); + }); + + it('should return alternative tokenKey when found by key param', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.getAlternatives(); + const result = IssuableFilteredSearchTokenKeys.searchByKeyParam( + `${tokenKeys[0].key}_${tokenKeys[0].param}`, + ); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByConditionUrl', () => { + it('should return null when condition url not found', () => { + const condition = IssuableFilteredSearchTokenKeys.searchByConditionUrl(null); + + expect(condition).toBeNull(); + }); + + it('should return condition when found by url', () => { + const conditions = IssuableFilteredSearchTokenKeys.getConditions(); + const result = IssuableFilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url); + + expect(result).toBe(conditions[0]); + }); + }); + + describe('searchByConditionKeyValue', () => { + it('should return null when condition tokenKey and value not found', () => { + const condition = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(null, null); + + expect(condition).toBeNull(); + }); + + it('should return condition when found by tokenKey and value', () => { + const conditions = IssuableFilteredSearchTokenKeys.getConditions(); + const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue( + conditions[0].tokenKey, + conditions[0].operator, + conditions[0].value, + ); + + expect(result).toEqual(conditions[0]); + }); + }); +}); diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js new file mode 100644 index 00000000000..a89d38b7a20 --- /dev/null +++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js @@ -0,0 +1,161 @@ +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; +import AccessorUtilities from '~/lib/utils/accessor'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +useLocalStorageSpy(); + +describe('RecentSearchesService', () => { + let service; + + beforeEach(() => { + service = new RecentSearchesService(); + localStorage.removeItem(service.localStorageKey); + }); + + describe('fetch', () => { + beforeEach(() => { + jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true); + }); + + it('should default to empty array', done => { + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then(items => { + expect(items).toEqual([]); + }) + .then(done) + .catch(done.fail); + }); + + it('should reject when unable to parse', done => { + jest.spyOn(localStorage, 'getItem').mockReturnValue('fail'); + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then(done.fail) + .catch(error => { + expect(error).toEqual(expect.any(SyntaxError)); + }) + .then(done) + .catch(done.fail); + }); + + it('should reject when service is unavailable', done => { + RecentSearchesService.isAvailable.mockReturnValue(false); + + service + .fetch() + .then(done.fail) + .catch(error => { + expect(error).toEqual(expect.any(Error)); + }) + .then(done) + .catch(done.fail); + }); + + it('should return items from localStorage', done => { + jest.spyOn(localStorage, 'getItem').mockReturnValue('["foo", "bar"]'); + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then(items => { + expect(items).toEqual(['foo', 'bar']); + }) + .then(done) + .catch(done.fail); + }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.mockReturnValue(false); + + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {}); + }); + + it('should not call .getItem', done => { + RecentSearchesService.prototype + .fetch() + .then(done.fail) + .catch(err => { + expect(err).toEqual(new RecentSearchesServiceError()); + expect(localStorage.getItem).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('setRecentSearches', () => { + beforeEach(() => { + jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true); + }); + + it('should save things in localStorage', () => { + jest.spyOn(localStorage, 'setItem'); + const items = ['foo', 'bar']; + service.save(items); + + expect(localStorage.setItem).toHaveBeenCalledWith(expect.any(String), JSON.stringify(items)); + }); + }); + + describe('save', () => { + beforeEach(() => { + jest.spyOn(localStorage, 'setItem'); + jest.spyOn(RecentSearchesService, 'isAvailable').mockImplementation(() => {}); + }); + + describe('if .isAvailable returns `true`', () => { + const searchesString = 'searchesString'; + const localStorageKey = 'localStorageKey'; + const recentSearchesService = { + localStorageKey, + }; + + beforeEach(() => { + RecentSearchesService.isAvailable.mockReturnValue(true); + + jest.spyOn(JSON, 'stringify').mockReturnValue(searchesString); + }); + + it('should call .setItem', () => { + RecentSearchesService.prototype.save.call(recentSearchesService); + + expect(localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString); + }); + }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.mockReturnValue(false); + }); + + it('should not call .setItem', () => { + RecentSearchesService.prototype.save(); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); + + describe('isAvailable', () => { + let isAvailable; + + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe'); + + isAvailable = RecentSearchesService.isAvailable(); + }); + + it('should call .isLocalStorageAccessSafe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + }); + + it('should return a boolean', () => { + expect(typeof isAvailable).toBe('boolean'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js new file mode 100644 index 00000000000..ea501423403 --- /dev/null +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -0,0 +1,389 @@ +import { escape } from 'lodash'; +import VisualTokenValue from '~/filtered_search/visual_token_value'; +import AjaxCache from '~/lib/utils/ajax_cache'; +import UsersCache from '~/lib/utils/users_cache'; +import DropdownUtils from '~/filtered_search//dropdown_utils'; +import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; + +describe('Filtered Search Visual Tokens', () => { + const findElements = tokenElement => { + const tokenNameElement = tokenElement.querySelector('.name'); + const tokenValueContainer = tokenElement.querySelector('.value-container'); + const tokenValueElement = tokenValueContainer.querySelector('.value'); + const tokenOperatorElement = tokenElement.querySelector('.operator'); + const tokenType = tokenNameElement.innerText.toLowerCase(); + const tokenValue = tokenValueElement.innerText; + const tokenOperator = tokenOperatorElement.innerText; + const subject = new VisualTokenValue(tokenValue, tokenType, tokenOperator); + return { subject, tokenValueContainer, tokenValueElement }; + }; + + let tokensContainer; + let authorToken; + let bugLabelToken; + + beforeEach(() => { + setFixtures(` + <ul class="tokens-container"> + ${FilteredSearchSpecHelper.createInputHTML()} + </ul> + `); + tokensContainer = document.querySelector('.tokens-container'); + + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); + bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug'); + }); + + describe('updateUserTokenAppearance', () => { + let usersCacheSpy; + + beforeEach(() => { + jest.spyOn(UsersCache, 'retrieve').mockImplementation(username => usersCacheSpy(username)); + }); + + it('ignores error if UsersCache throws', done => { + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + const dummyError = new Error('Earth rotated backwards'); + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.reject(dummyError); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(window.Flash.mock.calls.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('does nothing if user cannot be found', done => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(undefined); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueElement.innerText).toBe(tokenValue); + }) + .then(done) + .catch(done.fail); + }); + + it('replaces author token with avatar and display name', done => { + const dummyUser = { + name: 'Important Person', + avatar_url: 'https://host.invalid/mypics/avatar.png', + }; + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(dummyUser); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + const avatar = tokenValueElement.querySelector('img.avatar'); + + expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url); + expect(avatar.getAttribute('alt')).toBe(''); + }) + .then(done) + .catch(done.fail); + }); + + it('escapes user name when creating token', done => { + const dummyUser = { + name: '<script>', + avatar_url: `${gl.TEST_HOST}/mypics/avatar.png`, + }; + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(dummyUser); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + tokenValueElement.querySelector('.avatar').remove(); + + expect(tokenValueElement.innerHTML.trim()).toBe(escape(dummyUser.name)); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateLabelTokenColor', () => { + const jsonFixtureName = 'labels/project_labels.json'; + const dummyEndpoint = '/dummy/endpoint'; + + preloadFixtures(jsonFixtureName); + + let labelData; + + beforeAll(() => { + labelData = getJSONFixture(jsonFixtureName); + }); + + const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( + 'label', + '=', + '~doesnotexist', + ); + const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( + 'label', + '=', + '~"some space"', + ); + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${bugLabelToken.outerHTML} + ${missingLabelToken.outerHTML} + ${spaceLabelToken.outerHTML} + `); + + const filteredSearchInput = document.querySelector('.filtered-search'); + filteredSearchInput.dataset.runnerTagsEndpoint = `${dummyEndpoint}/admin/runners/tag_list`; + filteredSearchInput.dataset.labelsEndpoint = `${dummyEndpoint}/-/labels`; + filteredSearchInput.dataset.milestonesEndpoint = `${dummyEndpoint}/-/milestones`; + + AjaxCache.internalStorage = {}; + AjaxCache.internalStorage[`${filteredSearchInput.dataset.labelsEndpoint}.json`] = labelData; + }); + + const parseColor = color => { + const dummyElement = document.createElement('div'); + dummyElement.style.color = color; + return dummyElement.style.color; + }; + + const expectValueContainerStyle = (tokenValueContainer, label) => { + expect(tokenValueContainer.getAttribute('style')).not.toBe(null); + expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color)); + expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color)); + }; + + const findLabel = tokenValue => + labelData.find(label => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`); + + it('updates the color of a label token', done => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + + subject + .updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expectValueContainerStyle(tokenValueContainer, matchingLabel); + }) + .then(done) + .catch(done.fail); + }); + + it('updates the color of a label token with spaces', done => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + + subject + .updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expectValueContainerStyle(tokenValueContainer, matchingLabel); + }) + .then(done) + .catch(done.fail); + }); + + it('does not change color of a missing label', done => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(missingLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + + expect(matchingLabel).toBe(undefined); + + subject + .updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expect(tokenValueContainer.getAttribute('style')).toBe(null); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('setTokenStyle', () => { + let originalTextColor; + + beforeEach(() => { + originalTextColor = bugLabelToken.style.color; + }); + + it('should set backgroundColor', () => { + const originalBackgroundColor = bugLabelToken.style.backgroundColor; + const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'blue', 'white'); + + expect(token.style.backgroundColor).toEqual('blue'); + expect(token.style.backgroundColor).not.toEqual(originalBackgroundColor); + }); + + it('should set textColor', () => { + const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'white', 'black'); + + expect(token.style.color).toEqual('black'); + expect(token.style.color).not.toEqual(originalTextColor); + }); + + it('should add inverted class when textColor is #FFFFFF', () => { + const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'black', '#FFFFFF'); + + expect(token.style.color).toEqual('rgb(255, 255, 255)'); + expect(token.style.color).not.toEqual(originalTextColor); + expect(token.querySelector('.remove-token').classList.contains('inverted')).toEqual(true); + }); + }); + + describe('render', () => { + const setupSpies = subject => { + jest.spyOn(subject, 'updateLabelTokenColor').mockImplementation(() => {}); + const updateLabelTokenColorSpy = subject.updateLabelTokenColor; + + jest.spyOn(subject, 'updateUserTokenAppearance').mockImplementation(() => {}); + const updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance; + + return { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy }; + }; + + const keywordToken = FilteredSearchSpecHelper.createFilterVisualToken('search'); + const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken( + 'milestone', + 'upcoming', + ); + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${authorToken.outerHTML} + ${bugLabelToken.outerHTML} + ${keywordToken.outerHTML} + ${milestoneToken.outerHTML} + `); + }); + + it('renders a author token value element', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + + const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(1); + const expectedArgs = [tokenValueContainer, tokenValueElement]; + + expect(updateUserTokenAppearanceSpy.mock.calls[0]).toEqual(expectedArgs); + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0); + }); + + it('renders a label token value element', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + + const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(1); + const expectedArgs = [tokenValueContainer]; + + expect(updateLabelTokenColorSpy.mock.calls[0]).toEqual(expectedArgs); + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0); + }); + + it('renders a milestone token value element', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(milestoneToken); + + const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0); + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0); + }); + + it('does not update user token appearance for `none` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + + subject.tokenValue = 'none'; + + const { updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0); + }); + + it('does not update user token appearance for `None` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + + subject.tokenValue = 'None'; + + const { updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0); + }); + + it('does not update user token appearance for `any` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + + subject.tokenValue = 'any'; + + const { updateUserTokenAppearanceSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateUserTokenAppearanceSpy.mock.calls.length).toBe(0); + }); + + it('does not update label token color for `None` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + + subject.tokenValue = 'None'; + + const { updateLabelTokenColorSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0); + }); + + it('does not update label token color for `none` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + + subject.tokenValue = 'none'; + + const { updateLabelTokenColorSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0); + }); + + it('does not update label token color for `any` filter', () => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + + subject.tokenValue = 'any'; + + const { updateLabelTokenColorSpy } = setupSpies(subject); + subject.render(tokenValueContainer, tokenValueElement); + + expect(updateLabelTokenColorSpy.mock.calls.length).toBe(0); + }); + }); +}); diff --git a/spec/frontend/helpers/set_window_location_helper.js b/spec/frontend/helpers/set_window_location_helper.js new file mode 100644 index 00000000000..a94e73762c9 --- /dev/null +++ b/spec/frontend/helpers/set_window_location_helper.js @@ -0,0 +1,40 @@ +/** + * setWindowLocation allows for setting `window.location` + * (doing so directly is causing an error in jsdom) + * + * Example usage: + * assert(window.location.hash === undefined); + * setWindowLocation('http://example.com#foo') + * assert(window.location.hash === '#foo'); + * + * More information: + * https://github.com/facebook/jest/issues/890 + * + * @param url + */ +export default function setWindowLocation(url) { + const parsedUrl = new URL(url); + + const newLocationValue = [ + 'hash', + 'host', + 'hostname', + 'href', + 'origin', + 'pathname', + 'port', + 'protocol', + 'search', + ].reduce( + (location, prop) => ({ + ...location, + [prop]: parsedUrl[prop], + }), + {}, + ); + + Object.defineProperty(window, 'location', { + value: newLocationValue, + writable: true, + }); +} diff --git a/spec/frontend/helpers/set_window_location_helper_spec.js b/spec/frontend/helpers/set_window_location_helper_spec.js new file mode 100644 index 00000000000..2a2c024c824 --- /dev/null +++ b/spec/frontend/helpers/set_window_location_helper_spec.js @@ -0,0 +1,40 @@ +import setWindowLocation from './set_window_location_helper'; + +describe('setWindowLocation', () => { + const originalLocation = window.location; + + afterEach(() => { + window.location = originalLocation; + }); + + it.each` + url | property | value + ${'https://gitlab.com#foo'} | ${'hash'} | ${'#foo'} + ${'http://gitlab.com'} | ${'host'} | ${'gitlab.com'} + ${'http://gitlab.org'} | ${'hostname'} | ${'gitlab.org'} + ${'http://gitlab.org/foo#bar'} | ${'href'} | ${'http://gitlab.org/foo#bar'} + ${'http://gitlab.com'} | ${'origin'} | ${'http://gitlab.com'} + ${'http://gitlab.com/foo/bar/baz'} | ${'pathname'} | ${'/foo/bar/baz'} + ${'https://gitlab.com'} | ${'protocol'} | ${'https:'} + ${'http://gitlab.com#foo'} | ${'protocol'} | ${'http:'} + ${'http://gitlab.com:8080'} | ${'port'} | ${'8080'} + ${'http://gitlab.com?foo=bar&bar=foo'} | ${'search'} | ${'?foo=bar&bar=foo'} + `( + 'sets "window.location.$property" to be "$value" when called with: "$url"', + ({ url, property, value }) => { + expect(window.location).toBe(originalLocation); + + setWindowLocation(url); + + expect(window.location[property]).toBe(value); + }, + ); + + it.each([null, 1, undefined, false, '', 'gitlab.com'])( + 'throws an error when called with an invalid url: "%s"', + invalidUrl => { + expect(() => setWindowLocation(invalidUrl)).toThrow(new TypeError('Invalid URL')); + expect(window.location).toBe(originalLocation); + }, + ); +}); diff --git a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js new file mode 100644 index 00000000000..ac80ba58056 --- /dev/null +++ b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js @@ -0,0 +1,134 @@ +import Vue from 'vue'; +import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { resetStore } from 'jest/ide/helpers'; +import store from '~/ide/stores'; +import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue'; + +describe('IDE commit sidebar radio group', () => { + let vm; + + beforeEach(done => { + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '2'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('uses label if present', () => { + expect(vm.$el.textContent).toContain('test'); + }); + + it('uses slot if label is not present', done => { + vm.$destroy(); + + vm = new Vue({ + components: { + radioGroup, + }, + store, + render: createElement => + createElement('radio-group', { props: { value: '1' } }, 'Testing slot'), + }); + + vm.$mount(); + + Vue.nextTick(() => { + expect(vm.$el.textContent).toContain('Testing slot'); + + done(); + }); + }); + + it('updates store when changing radio button', done => { + vm.$el.querySelector('input').dispatchEvent(new Event('change')); + + Vue.nextTick(() => { + expect(store.state.commit.commitAction).toBe('1'); + + done(); + }); + }); + + describe('with input', () => { + beforeEach(done => { + vm.$destroy(); + + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '1'; + store.state.commit.newBranchName = 'test-123'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + showInput: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + it('renders input box when commitAction matches value', () => { + expect(vm.$el.querySelector('.form-control')).not.toBeNull(); + }); + + it('hides input when commitAction doesnt match value', done => { + store.state.commit.commitAction = '2'; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.form-control')).toBeNull(); + done(); + }); + }); + + it('updates branch name in store on input', done => { + const input = vm.$el.querySelector('.form-control'); + input.value = 'testing-123'; + input.dispatchEvent(new Event('input')); + + Vue.nextTick(() => { + expect(store.state.commit.newBranchName).toBe('testing-123'); + + done(); + }); + }); + + it('renders newBranchName if present', () => { + const input = vm.$el.querySelector('.form-control'); + + expect(input.value).toBe('test-123'); + }); + }); + + describe('tooltipTitle', () => { + it('returns title when disabled', () => { + vm.title = 'test title'; + vm.disabled = true; + + expect(vm.tooltipTitle).toBe('test title'); + }); + + it('returns blank when not disabled', () => { + vm.title = 'test title'; + + expect(vm.tooltipTitle).not.toBe('test title'); + }); + }); +}); diff --git a/spec/frontend/ide/components/file_row_extra_spec.js b/spec/frontend/ide/components/file_row_extra_spec.js new file mode 100644 index 00000000000..e78bacadebb --- /dev/null +++ b/spec/frontend/ide/components/file_row_extra_spec.js @@ -0,0 +1,170 @@ +import Vue from 'vue'; +import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { createStore } from '~/ide/stores'; +import FileRowExtra from '~/ide/components/file_row_extra.vue'; +import { file, resetStore } from '../helpers'; + +describe('IDE extra file row component', () => { + let Component; + let vm; + let unstagedFilesCount = 0; + let stagedFilesCount = 0; + let changesCount = 0; + + beforeAll(() => { + Component = Vue.extend(FileRowExtra); + }); + + beforeEach(() => { + vm = createComponentWithStore(Component, createStore(), { + file: { + ...file('test'), + }, + dropdownOpen: false, + }); + + jest.spyOn(vm, 'getUnstagedFilesCountForPath', 'get').mockReturnValue(() => unstagedFilesCount); + jest.spyOn(vm, 'getStagedFilesCountForPath', 'get').mockReturnValue(() => stagedFilesCount); + jest.spyOn(vm, 'getChangesInFolder', 'get').mockReturnValue(() => changesCount); + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + resetStore(vm.$store); + + stagedFilesCount = 0; + unstagedFilesCount = 0; + changesCount = 0; + }); + + describe('folderChangesTooltip', () => { + it('returns undefined when changes count is 0', () => { + changesCount = 0; + + expect(vm.folderChangesTooltip).toBe(undefined); + }); + + [{ input: 1, output: '1 changed file' }, { input: 2, output: '2 changed files' }].forEach( + ({ input, output }) => { + it('returns changed files count if changes count is not 0', () => { + changesCount = input; + + expect(vm.folderChangesTooltip).toBe(output); + }); + }, + ); + }); + + describe('show tree changes count', () => { + it('does not show for blobs', () => { + vm.file.type = 'blob'; + + expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null); + }); + + it('does not show when changes count is 0', () => { + vm.file.type = 'tree'; + + expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null); + }); + + it('does not show when tree is open', done => { + vm.file.type = 'tree'; + vm.file.opened = true; + changesCount = 1; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null); + + done(); + }); + }); + + it('shows for trees with changes', done => { + vm.file.type = 'tree'; + vm.file.opened = false; + changesCount = 1; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ide-tree-changes')).not.toBe(null); + + done(); + }); + }); + }); + + describe('changes file icon', () => { + it('hides when file is not changed', () => { + expect(vm.$el.querySelector('.file-changed-icon')).toBe(null); + }); + + it('shows when file is changed', done => { + vm.file.changed = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + + done(); + }); + }); + + it('shows when file is staged', done => { + vm.file.staged = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + + done(); + }); + }); + + it('shows when file is a tempFile', done => { + vm.file.tempFile = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + + done(); + }); + }); + + it('shows when file is renamed', done => { + vm.file.prevPath = 'original-file'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + + done(); + }); + }); + + it('hides when file is renamed', done => { + vm.file.prevPath = 'original-file'; + vm.file.type = 'tree'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).toBe(null); + + done(); + }); + }); + }); + + describe('merge request icon', () => { + it('hides when not a merge request change', () => { + expect(vm.$el.querySelector('.ic-git-merge')).toBe(null); + }); + + it('shows when a merge request change', done => { + vm.file.mrChange = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ic-git-merge')).not.toBe(null); + + done(); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js new file mode 100644 index 00000000000..30a09092f70 --- /dev/null +++ b/spec/frontend/ide/components/ide_review_spec.js @@ -0,0 +1,73 @@ +import Vue from 'vue'; +import IdeReview from '~/ide/components/ide_review.vue'; +import { createStore } from '~/ide/stores'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { trimText } from '../../helpers/text_helper'; +import { resetStore, file } from '../helpers'; +import { projectData } from '../mock_data'; + +describe('IDE review mode', () => { + const Component = Vue.extend(IdeReview); + let vm; + let store; + + beforeEach(() => { + store = createStore(); + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = Object.assign({}, projectData); + Vue.set(store.state.trees, 'abcproject/master', { + tree: [file('fileName')], + loading: false, + }); + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders list of files', () => { + expect(vm.$el.textContent).toContain('fileName'); + }); + + describe('merge request', () => { + beforeEach(() => { + store.state.currentMergeRequestId = '1'; + store.state.projects.abcproject.mergeRequests['1'] = { + iid: 123, + web_url: 'testing123', + }; + + return vm.$nextTick(); + }); + + it('renders edit dropdown', () => { + expect(vm.$el.querySelector('.btn')).not.toBe(null); + }); + + it('renders merge request link & IID', () => { + store.state.viewer = 'mrdiff'; + + return vm.$nextTick(() => { + const link = vm.$el.querySelector('.ide-review-sub-header'); + + expect(link.querySelector('a').getAttribute('href')).toBe('testing123'); + expect(trimText(link.textContent)).toBe('Merge request (!123)'); + }); + }); + + it('changes text to latest changes when viewer is not mrdiff', () => { + store.state.viewer = 'diff'; + + return vm.$nextTick(() => { + expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe( + 'Latest changes', + ); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js new file mode 100644 index 00000000000..bc8144f544c --- /dev/null +++ b/spec/frontend/ide/components/ide_status_bar_spec.js @@ -0,0 +1,127 @@ +import Vue from 'vue'; +import _ from 'lodash'; +import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { TEST_HOST } from '../../helpers/test_constants'; +import { createStore } from '~/ide/stores'; +import IdeStatusBar from '~/ide/components/ide_status_bar.vue'; +import { rightSidebarViews } from '~/ide/constants'; +import { projectData } from '../mock_data'; + +const TEST_PROJECT_ID = 'abcproject'; +const TEST_MERGE_REQUEST_ID = '9001'; +const TEST_MERGE_REQUEST_URL = `${TEST_HOST}merge-requests/${TEST_MERGE_REQUEST_ID}`; + +describe('ideStatusBar', () => { + let store; + let vm; + + const createComponent = () => { + vm = createComponentWithStore(Vue.extend(IdeStatusBar), store).$mount(); + }; + const findMRStatus = () => vm.$el.querySelector('.js-ide-status-mr'); + + beforeEach(() => { + store = createStore(); + store.state.currentProjectId = TEST_PROJECT_ID; + store.state.projects[TEST_PROJECT_ID] = _.clone(projectData); + store.state.currentBranchId = 'master'; + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('triggers a setInterval', () => { + expect(vm.intervalId).not.toBe(null); + }); + + it('renders the statusbar', () => { + expect(vm.$el.className).toBe('ide-status-bar'); + }); + + describe('commitAgeUpdate', () => { + beforeEach(() => { + jest.spyOn(vm, 'commitAgeUpdate').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('gets called every second', () => { + expect(vm.commitAgeUpdate).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1000); + + expect(vm.commitAgeUpdate.mock.calls.length).toEqual(1); + + jest.advanceTimersByTime(1000); + + expect(vm.commitAgeUpdate.mock.calls.length).toEqual(2); + }); + }); + + describe('getCommitPath', () => { + it('returns the path to the commit details', () => { + expect(vm.getCommitPath('abc123de')).toBe('/commit/abc123de'); + }); + }); + + describe('pipeline status', () => { + it('opens right sidebar on clicking icon', done => { + jest.spyOn(vm, 'openRightPane').mockImplementation(() => {}); + Vue.set(vm.$store.state.pipelines, 'latestPipeline', { + details: { + status: { + text: 'success', + details_path: 'test', + icon: 'status_success', + }, + }, + commit: { + author_gravatar_url: 'www', + }, + }); + + vm.$nextTick() + .then(() => { + vm.$el.querySelector('.ide-status-pipeline button').click(); + + expect(vm.openRightPane).toHaveBeenCalledWith(rightSidebarViews.pipelines); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('does not show merge request status', () => { + expect(findMRStatus()).toBe(null); + }); + }); + + describe('with merge request in store', () => { + beforeEach(() => { + store.state.projects[TEST_PROJECT_ID].mergeRequests = { + [TEST_MERGE_REQUEST_ID]: { + web_url: TEST_MERGE_REQUEST_URL, + references: { + short: `!${TEST_MERGE_REQUEST_ID}`, + }, + }, + }; + store.state.currentMergeRequestId = TEST_MERGE_REQUEST_ID; + + createComponent(); + }); + + it('shows merge request status', () => { + expect(findMRStatus().textContent.trim()).toEqual(`Merge request !${TEST_MERGE_REQUEST_ID}`); + expect(findMRStatus().querySelector('a').href).toEqual(TEST_MERGE_REQUEST_URL); + }); + }); +}); diff --git a/spec/frontend/ide/components/merge_requests/item_spec.js b/spec/frontend/ide/components/merge_requests/item_spec.js new file mode 100644 index 00000000000..6a2451ad263 --- /dev/null +++ b/spec/frontend/ide/components/merge_requests/item_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import router from '~/ide/ide_router'; +import Item from '~/ide/components/merge_requests/item.vue'; +import mountCompontent from '../../../helpers/vue_mount_component_helper'; + +describe('IDE merge request item', () => { + const Component = Vue.extend(Item); + let vm; + + beforeEach(() => { + vm = mountCompontent(Component, { + item: { + iid: 1, + projectPathWithNamespace: 'gitlab-org/gitlab-ce', + title: 'Merge request title', + }, + currentId: '1', + currentProjectId: 'gitlab-org/gitlab-ce', + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders merge requests data', () => { + expect(vm.$el.textContent).toContain('Merge request title'); + expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1'); + }); + + it('renders link with href', () => { + const expectedHref = router.resolve( + `/project/${vm.item.projectPathWithNamespace}/merge_requests/${vm.item.iid}`, + ).href; + + expect(vm.$el.tagName.toLowerCase()).toBe('a'); + expect(vm.$el).toHaveAttr('href', expectedHref); + }); + + it('renders icon if ID matches currentId', () => { + expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null); + }); + + it('does not render icon if ID does not match currentId', done => { + vm.currentId = '2'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null); + + done(); + }); + }); + + it('does not render icon if project ID does not match', done => { + vm.currentProjectId = 'test/test'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null); + + done(); + }); + }); +}); diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js new file mode 100644 index 00000000000..a418fdeb572 --- /dev/null +++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js @@ -0,0 +1,112 @@ +import Vue from 'vue'; +import createComponent from 'helpers/vue_mount_component_helper'; +import upload from '~/ide/components/new_dropdown/upload.vue'; + +describe('new dropdown upload', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(upload); + + vm = createComponent(Component, { + path: '', + }); + + vm.entryName = 'testing'; + + jest.spyOn(vm, '$emit'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('openFile', () => { + it('calls for each file', () => { + const files = ['test', 'test2', 'test3']; + + jest.spyOn(vm, 'readFile').mockImplementation(() => {}); + jest.spyOn(vm.$refs.fileUpload, 'files', 'get').mockReturnValue(files); + + vm.openFile(); + + expect(vm.readFile.mock.calls.length).toBe(3); + + files.forEach((file, i) => { + expect(vm.readFile.mock.calls[i]).toEqual([file]); + }); + }); + }); + + describe('readFile', () => { + beforeEach(() => { + jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => {}); + }); + + it('calls readAsDataURL for all files', () => { + const file = { + type: 'images/png', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file); + }); + }); + + describe('createFile', () => { + const textTarget = { + result: 'base64,cGxhaW4gdGV4dA==', + }; + const binaryTarget = { + result: 'base64,w4I=', + }; + const textFile = new File(['plain text'], 'textFile'); + + const binaryFile = { + name: 'binaryFile', + type: 'image/png', + }; + + beforeEach(() => { + jest.spyOn(FileReader.prototype, 'readAsText'); + }); + + it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', done => { + const waitForCreate = new Promise(resolve => vm.$on('create', resolve)); + + vm.createFile(textTarget, textFile); + + expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile); + + waitForCreate + .then(() => { + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: textFile.name, + type: 'blob', + content: 'plain text', + base64: false, + binary: false, + rawPath: '', + }); + }) + .then(done) + .catch(done.fail); + }); + + it('splits content on base64 if binary', () => { + vm.createFile(binaryTarget, binaryFile); + + expect(FileReader.prototype.readAsText).not.toHaveBeenCalledWith(textFile); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: binaryFile.name, + type: 'blob', + content: binaryTarget.result.split('base64,')[1], + base64: true, + binary: true, + rawPath: binaryTarget.result, + }); + }); + }); +}); |