summaryrefslogtreecommitdiff
path: root/spec/frontend/design_management/components
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 14:34:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 14:34:42 +0000
commit9f46488805e86b1bc341ea1620b866016c2ce5ed (patch)
treef9748c7e287041e37d6da49e0a29c9511dc34768 /spec/frontend/design_management/components
parentdfc92d081ea0332d69c8aca2f0e745cb48ae5e6d (diff)
downloadgitlab-ce-9f46488805e86b1bc341ea1620b866016c2ce5ed.tar.gz
Add latest changes from gitlab-org/gitlab@13-0-stable-ee
Diffstat (limited to 'spec/frontend/design_management/components')
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap42
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap104
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap115
-rw-r--r--spec/frontend/design_management/components/__snapshots__/image_spec.js.snap68
-rw-r--r--spec/frontend/design_management/components/delete_button_spec.js51
-rw-r--r--spec/frontend/design_management/components/design_note_pin_spec.js49
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap61
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap15
-rw-r--r--spec/frontend/design_management/components/design_notes/design_discussion_spec.js133
-rw-r--r--spec/frontend/design_management/components/design_notes/design_note_spec.js170
-rw-r--r--spec/frontend/design_management/components/design_notes/design_reply_form_spec.js182
-rw-r--r--spec/frontend/design_management/components/design_overlay_spec.js393
-rw-r--r--spec/frontend/design_management/components/design_presentation_spec.js546
-rw-r--r--spec/frontend/design_management/components/design_scaler_spec.js67
-rw-r--r--spec/frontend/design_management/components/image_spec.js133
-rw-r--r--spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap472
-rw-r--r--spec/frontend/design_management/components/list/item_spec.js168
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap61
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap28
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap29
-rw-r--r--spec/frontend/design_management/components/toolbar/index_spec.js123
-rw-r--r--spec/frontend/design_management/components/toolbar/pagination_button_spec.js61
-rw-r--r--spec/frontend/design_management/components/toolbar/pagination_spec.js79
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap79
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap455
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap111
-rw-r--r--spec/frontend/design_management/components/upload/button_spec.js59
-rw-r--r--spec/frontend/design_management/components/upload/design_dropzone_spec.js132
-rw-r--r--spec/frontend/design_management/components/upload/design_version_dropdown_spec.js114
-rw-r--r--spec/frontend/design_management/components/upload/mock_data/all_versions.js14
30 files changed, 4114 insertions, 0 deletions
diff --git a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap
new file mode 100644
index 00000000000..4828e8cb3c2
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap
@@ -0,0 +1,42 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design discussions component should match the snapshot of note when repositioning 1`] = `
+<button
+ aria-label="Comment form position"
+ class="position-absolute btn-transparent comment-indicator"
+ style="left: 10px; top: 10px; cursor: move;"
+ type="button"
+>
+ <icon-stub
+ name="image-comment-dark"
+ size="16"
+ />
+</button>
+`;
+
+exports[`Design discussions component should match the snapshot of note with index 1`] = `
+<button
+ aria-label="Comment '1' position"
+ class="position-absolute js-image-badge badge badge-pill"
+ style="left: 10px; top: 10px;"
+ type="button"
+>
+
+ 1
+
+</button>
+`;
+
+exports[`Design discussions component should match the snapshot of note without index 1`] = `
+<button
+ aria-label="Comment form position"
+ class="position-absolute btn-transparent comment-indicator"
+ style="left: 10px; top: 10px;"
+ type="button"
+>
+ <icon-stub
+ name="image-comment-dark"
+ size="16"
+ />
+</button>
+`;
diff --git a/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap
new file mode 100644
index 00000000000..189962c5b2e
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap
@@ -0,0 +1,104 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management design presentation component currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = `
+<div
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+>
+ <div
+ class="h-100 w-100 d-flex align-items-center position-relative"
+ >
+ <design-image-stub
+ image="test.jpg"
+ name="test"
+ scale="1"
+ />
+
+ <design-overlay-stub
+ currentcommentform="[object Object]"
+ dimensions="[object Object]"
+ notes=""
+ position="[object Object]"
+ />
+ </div>
+</div>
+`;
+
+exports[`Design management design presentation component currentCommentForm is null when isAnnotating is false 1`] = `
+<div
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+>
+ <div
+ class="h-100 w-100 d-flex align-items-center position-relative"
+ >
+ <design-image-stub
+ image="test.jpg"
+ name="test"
+ scale="1"
+ />
+
+ <design-overlay-stub
+ dimensions="[object Object]"
+ notes=""
+ position="[object Object]"
+ />
+ </div>
+</div>
+`;
+
+exports[`Design management design presentation component currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = `
+<div
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+>
+ <div
+ class="h-100 w-100 d-flex align-items-center position-relative"
+ >
+ <design-image-stub
+ image="test.jpg"
+ name="test"
+ scale="1"
+ />
+
+ <design-overlay-stub
+ dimensions="[object Object]"
+ notes=""
+ position="[object Object]"
+ />
+ </div>
+</div>
+`;
+
+exports[`Design management design presentation component renders empty state when no image provided 1`] = `
+<div
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+>
+ <div
+ class="h-100 w-100 d-flex align-items-center position-relative"
+ >
+ <!---->
+
+ <!---->
+ </div>
+</div>
+`;
+
+exports[`Design management design presentation component renders image and overlay when image provided 1`] = `
+<div
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+>
+ <div
+ class="h-100 w-100 d-flex align-items-center position-relative"
+ >
+ <design-image-stub
+ image="test.jpg"
+ name="test"
+ scale="1"
+ />
+
+ <design-overlay-stub
+ dimensions="[object Object]"
+ notes=""
+ position="[object Object]"
+ />
+ </div>
+</div>
+`;
diff --git a/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap
new file mode 100644
index 00000000000..cb4575cbd11
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap
@@ -0,0 +1,115 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management design scaler component minus and reset buttons are disabled when scale === 1 1`] = `
+<div
+ class="design-scaler btn-group"
+ role="group"
+>
+ <button
+ class="btn"
+ disabled="disabled"
+ >
+ <span
+ class="d-flex-center gl-icon s16"
+ >
+
+ –
+
+ </span>
+ </button>
+
+ <button
+ class="btn"
+ disabled="disabled"
+ >
+ <gl-icon-stub
+ name="redo"
+ size="16"
+ />
+ </button>
+
+ <button
+ class="btn"
+ >
+ <gl-icon-stub
+ name="plus"
+ size="16"
+ />
+ </button>
+</div>
+`;
+
+exports[`Design management design scaler component minus and reset buttons are enabled when scale > 1 1`] = `
+<div
+ class="design-scaler btn-group"
+ role="group"
+>
+ <button
+ class="btn"
+ >
+ <span
+ class="d-flex-center gl-icon s16"
+ >
+
+ –
+
+ </span>
+ </button>
+
+ <button
+ class="btn"
+ >
+ <gl-icon-stub
+ name="redo"
+ size="16"
+ />
+ </button>
+
+ <button
+ class="btn"
+ >
+ <gl-icon-stub
+ name="plus"
+ size="16"
+ />
+ </button>
+</div>
+`;
+
+exports[`Design management design scaler component plus button is disabled when scale === 2 1`] = `
+<div
+ class="design-scaler btn-group"
+ role="group"
+>
+ <button
+ class="btn"
+ >
+ <span
+ class="d-flex-center gl-icon s16"
+ >
+
+ –
+
+ </span>
+ </button>
+
+ <button
+ class="btn"
+ >
+ <gl-icon-stub
+ name="redo"
+ size="16"
+ />
+ </button>
+
+ <button
+ class="btn"
+ disabled="disabled"
+ >
+ <gl-icon-stub
+ name="plus"
+ size="16"
+ />
+ </button>
+</div>
+`;
diff --git a/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap
new file mode 100644
index 00000000000..acaa62b11eb
--- /dev/null
+++ b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap
@@ -0,0 +1,68 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management large image component renders image 1`] = `
+<div
+ class="m-auto js-design-image"
+>
+ <!---->
+
+ <img
+ alt="test"
+ class="mh-100 img-fluid"
+ src="test.jpg"
+ />
+</div>
+`;
+
+exports[`Design management large image component renders loading state 1`] = `
+<div
+ class="m-auto js-design-image"
+ isloading="true"
+>
+ <!---->
+
+ <img
+ alt=""
+ class="mh-100 img-fluid"
+ src=""
+ />
+</div>
+`;
+
+exports[`Design management large image component renders media broken icon on error 1`] = `
+<gl-icon-stub
+ class="text-secondary-100"
+ name="media-broken"
+ size="48"
+/>
+`;
+
+exports[`Design management large image component sets correct classes and styles if imageStyle is set 1`] = `
+<div
+ class="m-auto js-design-image"
+>
+ <!---->
+
+ <img
+ alt="test"
+ class="mh-100"
+ src="test.jpg"
+ style="width: 100px; height: 100px;"
+ />
+</div>
+`;
+
+exports[`Design management large image component zoom sets image style when zoomed 1`] = `
+<div
+ class="m-auto js-design-image"
+>
+ <!---->
+
+ <img
+ alt="test"
+ class="mh-100"
+ src="test.jpg"
+ style="width: 200px; height: 200px;"
+ />
+</div>
+`;
diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js
new file mode 100644
index 00000000000..9d3bcd98e44
--- /dev/null
+++ b/spec/frontend/design_management/components/delete_button_spec.js
@@ -0,0 +1,51 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import BatchDeleteButton from '~/design_management/components/delete_button.vue';
+
+describe('Batch delete button component', () => {
+ let wrapper;
+
+ const findButton = () => wrapper.find(GlDeprecatedButton);
+ const findModal = () => wrapper.find(GlModal);
+
+ function createComponent(isDeleting = false) {
+ wrapper = shallowMount(BatchDeleteButton, {
+ propsData: {
+ isDeleting,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders non-disabled button by default', () => {
+ createComponent();
+
+ expect(findButton().exists()).toBe(true);
+ expect(findButton().attributes('disabled')).toBeFalsy();
+ });
+
+ it('renders disabled button when design is deleting', () => {
+ createComponent(true);
+ expect(findButton().attributes('disabled')).toBeTruthy();
+ });
+
+ it('emits `deleteSelectedDesigns` event on modal ok click', () => {
+ createComponent();
+ findButton().vm.$emit('click');
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ findModal().vm.$emit('ok');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_note_pin_spec.js b/spec/frontend/design_management/components/design_note_pin_spec.js
new file mode 100644
index 00000000000..4f7260b1363
--- /dev/null
+++ b/spec/frontend/design_management/components/design_note_pin_spec.js
@@ -0,0 +1,49 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignNotePin from '~/design_management/components/design_note_pin.vue';
+
+describe('Design discussions component', () => {
+ let wrapper;
+
+ function createComponent(propsData = {}) {
+ wrapper = shallowMount(DesignNotePin, {
+ propsData: {
+ position: {
+ left: '10px',
+ top: '10px',
+ },
+ ...propsData,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should match the snapshot of note without index', () => {
+ createComponent();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('should match the snapshot of note with index', () => {
+ createComponent({ label: '1' });
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('should match the snapshot of note when repositioning', () => {
+ createComponent({ repositioning: true });
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('pinStyle', () => {
+ it('sets cursor to `move` when repositioning = true', () => {
+ createComponent({ repositioning: true });
+ expect(wrapper.vm.pinStyle.cursor).toBe('move');
+ });
+
+ it('does not set cursor when repositioning = false', () => {
+ createComponent();
+ expect(wrapper.vm.pinStyle.cursor).toBe(undefined);
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
new file mode 100644
index 00000000000..e071274cc81
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
@@ -0,0 +1,61 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design note component should match the snapshot 1`] = `
+<timeline-entry-item-stub
+ class="design-note note-form"
+ id="note_123"
+>
+ <user-avatar-link-stub
+ imgalt=""
+ imgcssclasses=""
+ imgsize="40"
+ imgsrc=""
+ linkhref=""
+ tooltipplacement="top"
+ tooltiptext=""
+ username=""
+ />
+
+ <div
+ class="d-flex justify-content-between"
+ >
+ <div>
+ <a
+ class="js-user-link"
+ data-user-id="author-id"
+ >
+ <span
+ class="note-header-author-name bold"
+ >
+
+ </span>
+
+ <!---->
+
+ <span
+ class="note-headline-light"
+ >
+ @
+ </span>
+ </a>
+
+ <span
+ class="note-headline-light note-headline-meta"
+ >
+ <span
+ class="system-note-message"
+ />
+
+ <!---->
+ </span>
+ </div>
+
+ <!---->
+ </div>
+
+ <div
+ class="note-text js-note-text md"
+ data-qa-selector="note_content"
+ />
+</timeline-entry-item-stub>
+`;
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
new file mode 100644
index 00000000000..e01c79e3520
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = `
+"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
+ <!---->
+ Comment
+</button>"
+`;
+
+exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = `
+"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
+ <!---->
+ Save comment
+</button>"
+`;
diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
new file mode 100644
index 00000000000..b16b26ff82f
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
@@ -0,0 +1,133 @@
+import { shallowMount } from '@vue/test-utils';
+import { ApolloMutation } from 'vue-apollo';
+import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
+import DesignNote from '~/design_management/components/design_notes/design_note.vue';
+import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+import createNoteMutation from '~/design_management/graphql/mutations/createNote.mutation.graphql';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+
+describe('Design discussions component', () => {
+ let wrapper;
+
+ const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder);
+ const findReplyForm = () => wrapper.find(DesignReplyForm);
+
+ const mutationVariables = {
+ mutation: createNoteMutation,
+ update: expect.anything(),
+ variables: {
+ input: {
+ noteableId: 'noteable-id',
+ body: 'test',
+ discussionId: '0',
+ },
+ },
+ };
+ const mutate = jest.fn(() => Promise.resolve());
+ const $apollo = {
+ mutate,
+ };
+
+ function createComponent(props = {}, data = {}) {
+ wrapper = shallowMount(DesignDiscussion, {
+ propsData: {
+ discussion: {
+ id: '0',
+ notes: [
+ {
+ id: '1',
+ },
+ {
+ id: '2',
+ },
+ ],
+ },
+ noteableId: 'noteable-id',
+ designId: 'design-id',
+ discussionIndex: 1,
+ ...props,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ stubs: {
+ ReplyPlaceholder,
+ ApolloMutation,
+ },
+ mocks: { $apollo },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders correct amount of discussion notes', () => {
+ createComponent();
+ expect(wrapper.findAll(DesignNote)).toHaveLength(2);
+ });
+
+ it('renders reply placeholder by default', () => {
+ createComponent();
+ expect(findReplyPlaceholder().exists()).toBe(true);
+ });
+
+ it('hides reply placeholder and opens form on placeholder click', () => {
+ createComponent();
+ findReplyPlaceholder().trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findReplyPlaceholder().exists()).toBe(false);
+ expect(findReplyForm().exists()).toBe(true);
+ });
+ });
+
+ it('calls mutation on submitting form and closes the form', () => {
+ createComponent({}, { discussionComment: 'test', isFormRendered: true });
+
+ findReplyForm().vm.$emit('submitForm');
+ expect(mutate).toHaveBeenCalledWith(mutationVariables);
+
+ return mutate()
+ .then(() => {
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(findReplyForm().exists()).toBe(false);
+ });
+ });
+
+ it('clears the discussion comment on closing comment form', () => {
+ createComponent({}, { discussionComment: 'test', isFormRendered: true });
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ findReplyForm().vm.$emit('cancelForm');
+
+ expect(wrapper.vm.discussionComment).toBe('');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(findReplyForm().exists()).toBe(false);
+ });
+ });
+
+ it('applies correct class to design notes when discussion is highlighted', () => {
+ createComponent(
+ {},
+ {
+ activeDiscussion: {
+ id: '1',
+ source: 'pin',
+ },
+ },
+ );
+
+ expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe(
+ true,
+ );
+ });
+});
diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js
new file mode 100644
index 00000000000..8b32d3022ee
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js
@@ -0,0 +1,170 @@
+import { shallowMount } from '@vue/test-utils';
+import { ApolloMutation } from 'vue-apollo';
+import DesignNote from '~/design_management/components/design_notes/design_note.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+
+const scrollIntoViewMock = jest.fn();
+const note = {
+ id: 'gid://gitlab/DiffNote/123',
+ author: {
+ id: 'author-id',
+ },
+ body: 'test',
+ userPermissions: {
+ adminNote: false,
+ },
+};
+HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
+
+const $route = {
+ hash: '#note_123',
+};
+
+const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } });
+
+describe('Design note component', () => {
+ let wrapper;
+
+ const findUserAvatar = () => wrapper.find(UserAvatarLink);
+ const findUserLink = () => wrapper.find('.js-user-link');
+ const findReplyForm = () => wrapper.find(DesignReplyForm);
+ const findEditButton = () => wrapper.find('.js-note-edit');
+ const findNoteContent = () => wrapper.find('.js-note-text');
+
+ function createComponent(props = {}, data = { isEditing: false }) {
+ wrapper = shallowMount(DesignNote, {
+ propsData: {
+ note: {},
+ ...props,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ mocks: {
+ $route,
+ $apollo: {
+ mutate,
+ },
+ },
+ stubs: {
+ ApolloMutation,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should match the snapshot', () => {
+ createComponent({
+ note,
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('should render an author', () => {
+ createComponent({
+ note,
+ });
+
+ expect(findUserAvatar().exists()).toBe(true);
+ expect(findUserLink().exists()).toBe(true);
+ });
+
+ it('should render a time ago tooltip if note has createdAt property', () => {
+ createComponent({
+ note: {
+ ...note,
+ createdAt: '2019-07-26T15:02:20Z',
+ },
+ });
+
+ expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
+ });
+
+ it('should trigger a scrollIntoView method', () => {
+ createComponent({
+ note,
+ });
+
+ expect(scrollIntoViewMock).toHaveBeenCalled();
+ });
+
+ it('should not render edit icon when user does not have a permission', () => {
+ createComponent({
+ note,
+ });
+
+ expect(findEditButton().exists()).toBe(false);
+ });
+
+ describe('when user has a permission to edit note', () => {
+ it('should open an edit form on edit button click', () => {
+ createComponent({
+ note: {
+ ...note,
+ userPermissions: {
+ adminNote: true,
+ },
+ },
+ });
+
+ findEditButton().trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findReplyForm().exists()).toBe(true);
+ expect(findNoteContent().exists()).toBe(false);
+ });
+ });
+
+ describe('when edit form is rendered', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ note: {
+ ...note,
+ userPermissions: {
+ adminNote: true,
+ },
+ },
+ },
+ { isEditing: true },
+ );
+ });
+
+ it('should not render note content and should render reply form', () => {
+ expect(findNoteContent().exists()).toBe(false);
+ expect(findReplyForm().exists()).toBe(true);
+ });
+
+ it('hides the form on hideForm event', () => {
+ findReplyForm().vm.$emit('cancelForm');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findReplyForm().exists()).toBe(false);
+ expect(findNoteContent().exists()).toBe(true);
+ });
+ });
+
+ it('calls a mutation on submitForm event and hides a form', () => {
+ findReplyForm().vm.$emit('submitForm');
+ expect(mutate).toHaveBeenCalled();
+
+ return mutate()
+ .then(() => {
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(findReplyForm().exists()).toBe(false);
+ expect(findNoteContent().exists()).toBe(true);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
new file mode 100644
index 00000000000..34b8f1f9fa8
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
@@ -0,0 +1,182 @@
+import { mount } from '@vue/test-utils';
+import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+
+const showModal = jest.fn();
+
+const GlModal = {
+ template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
+ methods: {
+ show: showModal,
+ },
+};
+
+describe('Design reply form component', () => {
+ let wrapper;
+
+ const findTextarea = () => wrapper.find('textarea');
+ const findSubmitButton = () => wrapper.find({ ref: 'submitButton' });
+ const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
+ const findModal = () => wrapper.find({ ref: 'cancelCommentModal' });
+
+ function createComponent(props = {}) {
+ wrapper = mount(DesignReplyForm, {
+ propsData: {
+ value: '',
+ isSaving: false,
+ ...props,
+ },
+ stubs: { GlModal },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('textarea has focus after component mount', () => {
+ createComponent();
+
+ expect(findTextarea().element).toEqual(document.activeElement);
+ });
+
+ it('renders button text as "Comment" when creating a comment', () => {
+ createComponent();
+
+ expect(findSubmitButton().html()).toMatchSnapshot();
+ });
+
+ it('renders button text as "Save comment" when creating a comment', () => {
+ createComponent({ isNewComment: false });
+
+ expect(findSubmitButton().html()).toMatchSnapshot();
+ });
+
+ describe('when form has no text', () => {
+ beforeEach(() => {
+ createComponent({
+ value: '',
+ });
+ });
+
+ it('submit button is disabled', () => {
+ expect(findSubmitButton().attributes().disabled).toBeTruthy();
+ });
+
+ it('does not emit submitForm event on textarea ctrl+enter keydown', () => {
+ findTextarea().trigger('keydown.enter', {
+ ctrlKey: true,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('submitForm')).toBeFalsy();
+ });
+ });
+
+ it('does not emit submitForm event on textarea meta+enter keydown', () => {
+ findTextarea().trigger('keydown.enter', {
+ metaKey: true,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('submitForm')).toBeFalsy();
+ });
+ });
+
+ it('emits cancelForm event on pressing escape button on textarea', () => {
+ findTextarea().trigger('keyup.esc');
+
+ expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ });
+
+ it('emits cancelForm event on clicking Cancel button', () => {
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.emitted('cancelForm')).toHaveLength(1);
+ });
+ });
+
+ describe('when form has text', () => {
+ beforeEach(() => {
+ createComponent({
+ value: 'test',
+ });
+ });
+
+ it('submit button is enabled', () => {
+ expect(findSubmitButton().attributes().disabled).toBeFalsy();
+ });
+
+ it('emits submitForm event on Comment button click', () => {
+ findSubmitButton().vm.$emit('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('submitForm')).toBeTruthy();
+ });
+ });
+
+ it('emits submitForm event on textarea ctrl+enter keydown', () => {
+ findTextarea().trigger('keydown.enter', {
+ ctrlKey: true,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('submitForm')).toBeTruthy();
+ });
+ });
+
+ it('emits submitForm event on textarea meta+enter keydown', () => {
+ findTextarea().trigger('keydown.enter', {
+ metaKey: true,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('submitForm')).toBeTruthy();
+ });
+ });
+
+ it('emits input event on changing textarea content', () => {
+ findTextarea().setValue('test2');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('input')).toBeTruthy();
+ });
+ });
+
+ it('emits cancelForm event on Escape key if text was not changed', () => {
+ findTextarea().trigger('keyup.esc');
+
+ expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ });
+
+ it('opens confirmation modal on Escape key when text has changed', () => {
+ wrapper.setProps({ value: 'test2' });
+
+ return wrapper.vm.$nextTick().then(() => {
+ findTextarea().trigger('keyup.esc');
+ expect(showModal).toHaveBeenCalled();
+ });
+ });
+
+ it('emits cancelForm event on Cancel button click if text was not changed', () => {
+ findCancelButton().trigger('click');
+
+ expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ });
+
+ it('opens confirmation modal on Cancel button click when text has changed', () => {
+ wrapper.setProps({ value: 'test2' });
+
+ return wrapper.vm.$nextTick().then(() => {
+ findCancelButton().trigger('click');
+ expect(showModal).toHaveBeenCalled();
+ });
+ });
+
+ it('emits cancelForm event on modal Ok button click', () => {
+ findTextarea().trigger('keyup.esc');
+ findModal().vm.$emit('ok');
+
+ expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js
new file mode 100644
index 00000000000..1c9b130aca6
--- /dev/null
+++ b/spec/frontend/design_management/components/design_overlay_spec.js
@@ -0,0 +1,393 @@
+import { mount } from '@vue/test-utils';
+import DesignOverlay from '~/design_management/components/design_overlay.vue';
+import updateActiveDiscussion from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql';
+import notes from '../mock_data/notes';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management/constants';
+
+const mutate = jest.fn(() => Promise.resolve());
+
+describe('Design overlay component', () => {
+ let wrapper;
+
+ const mockDimensions = { width: 100, height: 100 };
+ const mockNoteNotAuthorised = {
+ id: 'note-not-authorised',
+ discussion: { id: 'discussion-not-authorised' },
+ position: {
+ x: 1,
+ y: 80,
+ ...mockDimensions,
+ },
+ userPermissions: {},
+ };
+
+ const findOverlay = () => wrapper.find('.image-diff-overlay');
+ const findAllNotes = () => wrapper.findAll('.js-image-badge');
+ const findCommentBadge = () => wrapper.find('.comment-indicator');
+ const findFirstBadge = () => findAllNotes().at(0);
+ const findSecondBadge = () => findAllNotes().at(1);
+
+ const clickAndDragBadge = (elem, fromPoint, toPoint) => {
+ elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y });
+ return wrapper.vm.$nextTick().then(() => {
+ elem.trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y });
+ return wrapper.vm.$nextTick();
+ });
+ };
+
+ function createComponent(props = {}, data = {}) {
+ wrapper = mount(DesignOverlay, {
+ propsData: {
+ dimensions: mockDimensions,
+ position: {
+ top: '0',
+ left: '0',
+ },
+ ...props,
+ },
+ data() {
+ return {
+ activeDiscussion: {
+ id: null,
+ source: null,
+ },
+ ...data,
+ };
+ },
+ mocks: {
+ $apollo: {
+ mutate,
+ },
+ },
+ });
+ }
+
+ it('should have correct inline style', () => {
+ createComponent();
+
+ expect(wrapper.find('.image-diff-overlay').attributes().style).toBe(
+ 'width: 100px; height: 100px; top: 0px; left: 0px;',
+ );
+ });
+
+ it('should emit `openCommentForm` when clicking on overlay', () => {
+ createComponent();
+ const newCoordinates = {
+ x: 10,
+ y: 10,
+ };
+
+ wrapper
+ .find('.image-diff-overlay-add-comment')
+ .trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('openCommentForm')).toEqual([
+ [{ x: newCoordinates.x, y: newCoordinates.y }],
+ ]);
+ });
+ });
+
+ describe('with notes', () => {
+ beforeEach(() => {
+ createComponent({
+ notes,
+ });
+ });
+
+ it('should render a correct amount of notes', () => {
+ expect(findAllNotes()).toHaveLength(notes.length);
+ });
+
+ it('should have a correct style for each note badge', () => {
+ expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;');
+ expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;');
+ });
+
+ it('should recalculate badges positions on window resize', () => {
+ createComponent({
+ notes,
+ dimensions: {
+ width: 400,
+ height: 400,
+ },
+ });
+
+ expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;');
+
+ wrapper.setProps({
+ dimensions: {
+ width: 200,
+ height: 200,
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;');
+ });
+ });
+
+ it('should call an update active discussion mutation when clicking a note without moving it', () => {
+ const note = notes[0];
+ const { position } = note;
+ const mutationVariables = {
+ mutation: updateActiveDiscussion,
+ variables: {
+ id: note.id,
+ source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
+ },
+ };
+
+ findFirstBadge().trigger('mousedown', { clientX: position.x, clientY: position.y });
+
+ return wrapper.vm.$nextTick().then(() => {
+ findFirstBadge().trigger('mouseup', { clientX: position.x, clientY: position.y });
+ expect(mutate).toHaveBeenCalledWith(mutationVariables);
+ });
+ });
+
+ it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => {
+ wrapper.setData({
+ activeDiscussion: {
+ id: notes[0].id,
+ source: 'discussion',
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findSecondBadge().classes()).toContain('inactive');
+ });
+ });
+ });
+
+ describe('when moving notes', () => {
+ it('should update badge style when note is being moved', () => {
+ createComponent({
+ notes,
+ });
+
+ const { position } = notes[0];
+
+ return clickAndDragBadge(
+ findFirstBadge(),
+ { x: position.x, y: position.y },
+ { x: 20, y: 20 },
+ ).then(() => {
+ expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 20px; cursor: move;');
+ });
+ });
+
+ it('should emit `moveNote` event when note-moving action ends', () => {
+ createComponent({ notes });
+ const note = notes[0];
+ const { position } = note;
+ const newCoordinates = { x: 20, y: 20 };
+
+ wrapper.setData({
+ movingNoteNewPosition: {
+ ...position,
+ ...newCoordinates,
+ },
+ movingNoteStartPosition: {
+ noteId: notes[0].id,
+ discussionId: notes[0].discussion.id,
+ ...position,
+ },
+ });
+
+ const badge = findFirstBadge();
+ return clickAndDragBadge(badge, { x: position.x, y: position.y }, newCoordinates)
+ .then(() => {
+ badge.trigger('mouseup');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.emitted('moveNote')).toEqual([
+ [
+ {
+ noteId: notes[0].id,
+ discussionId: notes[0].discussion.id,
+ coordinates: newCoordinates,
+ },
+ ],
+ ]);
+ });
+ });
+
+ it('should do nothing if [adminNote] permission is not present', () => {
+ createComponent({
+ dimensions: mockDimensions,
+ notes: [mockNoteNotAuthorised],
+ });
+
+ const badge = findAllNotes().at(0);
+ return clickAndDragBadge(
+ badge,
+ { x: mockNoteNotAuthorised.x, y: mockNoteNotAuthorised.y },
+ { x: 20, y: 20 },
+ ).then(() => {
+ expect(wrapper.vm.movingNoteStartPosition).toBeNull();
+ expect(findFirstBadge().attributes().style).toBe('left: 1px; top: 80px;');
+ });
+ });
+ });
+
+ describe('with a new form', () => {
+ it('should render a new comment badge', () => {
+ createComponent({
+ currentCommentForm: {
+ ...notes[0].position,
+ },
+ });
+
+ expect(findCommentBadge().exists()).toBe(true);
+ expect(findCommentBadge().attributes().style).toBe('left: 10px; top: 15px;');
+ });
+
+ describe('when moving the comment badge', () => {
+ it('should update badge style to reflect new position', () => {
+ const { position } = notes[0];
+
+ createComponent({
+ currentCommentForm: {
+ ...position,
+ },
+ });
+
+ return clickAndDragBadge(
+ findCommentBadge(),
+ { x: position.x, y: position.y },
+ { x: 20, y: 20 },
+ ).then(() => {
+ expect(findCommentBadge().attributes().style).toBe(
+ 'left: 20px; top: 20px; cursor: move;',
+ );
+ });
+ });
+
+ it('should update badge style when note-moving action ends', () => {
+ const { position } = notes[0];
+ createComponent({
+ currentCommentForm: {
+ ...position,
+ },
+ });
+
+ const commentBadge = findCommentBadge();
+ const toPoint = { x: 20, y: 20 };
+
+ return clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint)
+ .then(() => {
+ commentBadge.trigger('mouseup');
+ // simulates the currentCommentForm being updated in index.vue component, and
+ // propagated back down to this prop
+ wrapper.setProps({
+ currentCommentForm: { height: position.height, width: position.width, ...toPoint },
+ });
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(commentBadge.attributes().style).toBe('left: 20px; top: 20px;');
+ });
+ });
+
+ it.each`
+ element | getElementFunc | event
+ ${'overlay'} | ${findOverlay} | ${'mouseleave'}
+ ${'comment badge'} | ${findCommentBadge} | ${'mouseup'}
+ `(
+ 'should emit `openCommentForm` event when $event fired on $element element',
+ ({ getElementFunc, event }) => {
+ createComponent({
+ notes,
+ currentCommentForm: {
+ ...notes[0].position,
+ },
+ });
+
+ const newCoordinates = { x: 20, y: 20 };
+ wrapper.setData({
+ movingNoteStartPosition: {
+ ...notes[0].position,
+ },
+ movingNoteNewPosition: {
+ ...notes[0].position,
+ ...newCoordinates,
+ },
+ });
+
+ getElementFunc().trigger(event);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]);
+ });
+ },
+ );
+ });
+ });
+
+ describe('getMovingNotePositionDelta', () => {
+ it('should calculate delta correctly from state', () => {
+ createComponent();
+
+ wrapper.setData({
+ movingNoteStartPosition: {
+ clientX: 10,
+ clientY: 20,
+ },
+ });
+
+ const mockMouseEvent = {
+ clientX: 30,
+ clientY: 10,
+ };
+
+ expect(wrapper.vm.getMovingNotePositionDelta(mockMouseEvent)).toEqual({
+ deltaX: 20,
+ deltaY: -10,
+ });
+ });
+ });
+
+ describe('isPositionInOverlay', () => {
+ createComponent({ dimensions: mockDimensions });
+
+ it.each`
+ test | coordinates | expectedResult
+ ${'within overlay bounds'} | ${{ x: 50, y: 50 }} | ${true}
+ ${'outside overlay bounds'} | ${{ x: 101, y: 101 }} | ${false}
+ `('returns [$expectedResult] when position is $test', ({ coordinates, expectedResult }) => {
+ const position = { ...mockDimensions, ...coordinates };
+
+ expect(wrapper.vm.isPositionInOverlay(position)).toBe(expectedResult);
+ });
+ });
+
+ describe('getNoteRelativePosition', () => {
+ it('calculates position correctly', () => {
+ createComponent({ dimensions: mockDimensions });
+ const position = { x: 50, y: 50, width: 200, height: 200 };
+
+ expect(wrapper.vm.getNoteRelativePosition(position)).toEqual({ left: 25, top: 25 });
+ });
+ });
+
+ describe('canMoveNote', () => {
+ it.each`
+ adminNotePermission | canMoveNoteResult
+ ${true} | ${true}
+ ${false} | ${false}
+ ${undefined} | ${false}
+ `(
+ 'returns [$canMoveNoteResult] when [adminNote permission] is [$adminNotePermission]',
+ ({ adminNotePermission, canMoveNoteResult }) => {
+ createComponent();
+
+ const note = {
+ userPermissions: {
+ adminNote: adminNotePermission,
+ },
+ };
+ expect(wrapper.vm.canMoveNote(note)).toBe(canMoveNoteResult);
+ },
+ );
+ });
+});
diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js
new file mode 100644
index 00000000000..8a709393d92
--- /dev/null
+++ b/spec/frontend/design_management/components/design_presentation_spec.js
@@ -0,0 +1,546 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignPresentation from '~/design_management/components/design_presentation.vue';
+import DesignOverlay from '~/design_management/components/design_overlay.vue';
+
+const mockOverlayData = {
+ overlayDimensions: {
+ width: 100,
+ height: 100,
+ },
+ overlayPosition: {
+ top: '0',
+ left: '0',
+ },
+};
+
+describe('Design management design presentation component', () => {
+ let wrapper;
+
+ function createComponent(
+ { image, imageName, discussions = [], isAnnotating = false } = {},
+ data = {},
+ stubs = {},
+ ) {
+ wrapper = shallowMount(DesignPresentation, {
+ propsData: {
+ image,
+ imageName,
+ discussions,
+ isAnnotating,
+ },
+ stubs,
+ });
+
+ wrapper.setData(data);
+ wrapper.element.scrollTo = jest.fn();
+ }
+
+ const findOverlayCommentButton = () => wrapper.find('.image-diff-overlay-add-comment');
+
+ /**
+ * Spy on $refs and mock given values
+ * @param {Object} viewportDimensions {width, height}
+ * @param {Object} childDimensions {width, height}
+ * @param {Float} scrollTopPerc 0 < x < 1
+ * @param {Float} scrollLeftPerc 0 < x < 1
+ */
+ function mockRefDimensions(
+ ref,
+ viewportDimensions,
+ childDimensions,
+ scrollTopPerc,
+ scrollLeftPerc,
+ ) {
+ jest.spyOn(ref, 'scrollWidth', 'get').mockReturnValue(childDimensions.width);
+ jest.spyOn(ref, 'scrollHeight', 'get').mockReturnValue(childDimensions.height);
+ jest.spyOn(ref, 'offsetWidth', 'get').mockReturnValue(viewportDimensions.width);
+ jest.spyOn(ref, 'offsetHeight', 'get').mockReturnValue(viewportDimensions.height);
+ jest
+ .spyOn(ref, 'scrollLeft', 'get')
+ .mockReturnValue((childDimensions.width - viewportDimensions.width) * scrollLeftPerc);
+ jest
+ .spyOn(ref, 'scrollTop', 'get')
+ .mockReturnValue((childDimensions.height - viewportDimensions.height) * scrollTopPerc);
+ }
+
+ function clickDragExplore(startCoords, endCoords, { useTouchEvents, mouseup } = {}) {
+ const event = useTouchEvents
+ ? {
+ mousedown: 'touchstart',
+ mousemove: 'touchmove',
+ mouseup: 'touchend',
+ }
+ : {
+ mousedown: 'mousedown',
+ mousemove: 'mousemove',
+ mouseup: 'mouseup',
+ };
+
+ const addCommentOverlay = findOverlayCommentButton();
+
+ // triggering mouse events on this element best simulates
+ // reality, as it is the lowest-level node that needs to
+ // respond to mouse events
+ addCommentOverlay.trigger(event.mousedown, {
+ clientX: startCoords.clientX,
+ clientY: startCoords.clientY,
+ });
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ addCommentOverlay.trigger(event.mousemove, {
+ clientX: endCoords.clientX,
+ clientY: endCoords.clientY,
+ });
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ if (mouseup) {
+ addCommentOverlay.trigger(event.mouseup);
+ return wrapper.vm.$nextTick();
+ }
+
+ return undefined;
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders image and overlay when image provided', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders empty state when no image provided', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('openCommentForm event emits correct data', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+
+ wrapper.vm.openCommentForm({ x: 1, y: 1 });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('openCommentForm')).toEqual([
+ [{ ...mockOverlayData.overlayDimensions, x: 1, y: 1 }],
+ ]);
+ });
+ });
+
+ describe('currentCommentForm', () => {
+ it('is null when isAnnotating is false', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.currentCommentForm).toBeNull();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('is null when isAnnotating is true but annotation position is falsey', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ isAnnotating: true,
+ },
+ mockOverlayData,
+ );
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.currentCommentForm).toBeNull();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('is equal to current annotation position when isAnnotating is true', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ isAnnotating: true,
+ },
+ {
+ ...mockOverlayData,
+ currentAnnotationPosition: {
+ x: 1,
+ y: 1,
+ width: 100,
+ height: 100,
+ },
+ },
+ );
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.currentCommentForm).toEqual({
+ x: 1,
+ y: 1,
+ width: 100,
+ height: 100,
+ });
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('setOverlayPosition', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('sets overlay position correctly when overlay is smaller than viewport', () => {
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200);
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200);
+
+ wrapper.vm.setOverlayPosition();
+ expect(wrapper.vm.overlayPosition).toEqual({
+ left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`,
+ top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`,
+ });
+ });
+
+ it('sets overlay position correctly when overlay width is larger than viewports', () => {
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(50);
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200);
+
+ wrapper.vm.setOverlayPosition();
+ expect(wrapper.vm.overlayPosition).toEqual({
+ left: '0',
+ top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`,
+ });
+ });
+
+ it('sets overlay position correctly when overlay height is larger than viewports', () => {
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200);
+ jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(50);
+
+ wrapper.vm.setOverlayPosition();
+ expect(wrapper.vm.overlayPosition).toEqual({
+ left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`,
+ top: '0',
+ });
+ });
+ });
+
+ describe('getViewportCenter', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+ });
+
+ it('calculate center correctly with no scroll', () => {
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 10, height: 10 },
+ { width: 20, height: 20 },
+ 0,
+ 0,
+ );
+
+ expect(wrapper.vm.getViewportCenter()).toEqual({
+ x: 5,
+ y: 5,
+ });
+ });
+
+ it('calculate center correctly with some scroll', () => {
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 10, height: 10 },
+ { width: 20, height: 20 },
+ 0.5,
+ 0.5,
+ );
+
+ expect(wrapper.vm.getViewportCenter()).toEqual({
+ x: 10,
+ y: 10,
+ });
+ });
+
+ it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => {
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 20, height: 20 },
+ { width: 20, height: 20 },
+ 0.5,
+ 0.5,
+ );
+
+ expect(wrapper.vm.getViewportCenter()).toEqual({
+ x: 10,
+ y: 10,
+ });
+ });
+ });
+
+ describe('scaleZoomFocalPoint', () => {
+ it('scales focal point correctly when zooming in', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ {
+ ...mockOverlayData,
+ zoomFocalPoint: {
+ x: 5,
+ y: 5,
+ width: 50,
+ height: 50,
+ },
+ },
+ );
+
+ wrapper.vm.scaleZoomFocalPoint();
+ expect(wrapper.vm.zoomFocalPoint).toEqual({
+ x: 10,
+ y: 10,
+ width: 100,
+ height: 100,
+ });
+ });
+
+ it('scales focal point correctly when zooming out', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ {
+ ...mockOverlayData,
+ zoomFocalPoint: {
+ x: 10,
+ y: 10,
+ width: 200,
+ height: 200,
+ },
+ },
+ );
+
+ wrapper.vm.scaleZoomFocalPoint();
+ expect(wrapper.vm.zoomFocalPoint).toEqual({
+ x: 5,
+ y: 5,
+ width: 100,
+ height: 100,
+ });
+ });
+ });
+
+ describe('onImageResize', () => {
+ it('sets zoom focal point on initial load', () => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ );
+
+ wrapper.setMethods({
+ shiftZoomFocalPoint: jest.fn(),
+ scaleZoomFocalPoint: jest.fn(),
+ scrollToFocalPoint: jest.fn(),
+ });
+
+ wrapper.vm.onImageResize({ width: 10, height: 10 });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.shiftZoomFocalPoint).toHaveBeenCalled();
+ expect(wrapper.vm.initialLoad).toBe(false);
+ });
+ });
+
+ it('calls scaleZoomFocalPoint and scrollToFocalPoint after initial load', () => {
+ wrapper.vm.onImageResize({ width: 10, height: 10 });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.scaleZoomFocalPoint).toHaveBeenCalled();
+ expect(wrapper.vm.scrollToFocalPoint).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('onPresentationMousedown', () => {
+ it.each`
+ scenario | width | height
+ ${'width overflows'} | ${101} | ${100}
+ ${'height overflows'} | ${100} | ${101}
+ ${'width and height overflows'} | ${200} | ${200}
+ `('sets lastDragPosition when design $scenario', ({ width, height }) => {
+ createComponent();
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 100, height: 100 },
+ { width, height },
+ );
+
+ const newLastDragPosition = { x: 2, y: 2 };
+ wrapper.vm.onPresentationMousedown({
+ clientX: newLastDragPosition.x,
+ clientY: newLastDragPosition.y,
+ });
+
+ expect(wrapper.vm.lastDragPosition).toStrictEqual(newLastDragPosition);
+ });
+
+ it('does not set lastDragPosition if design does not overflow', () => {
+ const lastDragPosition = { x: 1, y: 1 };
+
+ createComponent({}, { lastDragPosition });
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 100, height: 100 },
+ { width: 50, height: 50 },
+ );
+
+ wrapper.vm.onPresentationMousedown({ clientX: 2, clientY: 2 });
+
+ // check lastDragPosition is unchanged
+ expect(wrapper.vm.lastDragPosition).toStrictEqual(lastDragPosition);
+ });
+ });
+
+ describe('getAnnotationPositon', () => {
+ it.each`
+ coordinates | overlayDimensions | position
+ ${{ x: 100, y: 100 }} | ${{ width: 50, height: 50 }} | ${{ x: 100, y: 100, width: 50, height: 50 }}
+ ${{ x: 100.2, y: 100.5 }} | ${{ width: 50.6, height: 50.0 }} | ${{ x: 100, y: 101, width: 51, height: 50 }}
+ `('returns correct annotation position', ({ coordinates, overlayDimensions, position }) => {
+ createComponent(undefined, {
+ overlayDimensions: {
+ width: overlayDimensions.width,
+ height: overlayDimensions.height,
+ },
+ });
+
+ expect(wrapper.vm.getAnnotationPositon(coordinates)).toStrictEqual(position);
+ });
+ });
+
+ describe('when design is overflowing', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ image: 'test.jpg',
+ imageName: 'test',
+ },
+ mockOverlayData,
+ {
+ 'design-overlay': DesignOverlay,
+ },
+ );
+
+ // mock a design that overflows
+ mockRefDimensions(
+ wrapper.vm.$refs.presentationViewport,
+ { width: 10, height: 10 },
+ { width: 20, height: 20 },
+ 0,
+ 0,
+ );
+ });
+
+ it('opens a comment form if design was not dragged', () => {
+ const addCommentOverlay = findOverlayCommentButton();
+ const startCoords = {
+ clientX: 1,
+ clientY: 1,
+ };
+
+ addCommentOverlay.trigger('mousedown', {
+ clientX: startCoords.clientX,
+ clientY: startCoords.clientY,
+ });
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ addCommentOverlay.trigger('mouseup');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.emitted('openCommentForm')).toBeDefined();
+ });
+ });
+
+ describe('when clicking and dragging', () => {
+ it.each`
+ description | useTouchEvents
+ ${'with touch events'} | ${true}
+ ${'without touch events'} | ${false}
+ `('calls scrollTo with correct arguments $description', ({ useTouchEvents }) => {
+ return clickDragExplore(
+ { clientX: 0, clientY: 0 },
+ { clientX: 10, clientY: 10 },
+ { useTouchEvents },
+ ).then(() => {
+ expect(wrapper.element.scrollTo).toHaveBeenCalledTimes(1);
+ expect(wrapper.element.scrollTo).toHaveBeenCalledWith(-10, -10);
+ });
+ });
+
+ it('does not open a comment form when drag position exceeds buffer', () => {
+ return clickDragExplore(
+ { clientX: 0, clientY: 0 },
+ { clientX: 10, clientY: 10 },
+ { mouseup: true },
+ ).then(() => {
+ expect(wrapper.emitted('openCommentForm')).toBeFalsy();
+ });
+ });
+
+ it('opens a comment form when drag position is within buffer', () => {
+ return clickDragExplore(
+ { clientX: 0, clientY: 0 },
+ { clientX: 1, clientY: 0 },
+ { mouseup: true },
+ ).then(() => {
+ expect(wrapper.emitted('openCommentForm')).toBeDefined();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js
new file mode 100644
index 00000000000..b06d2f924df
--- /dev/null
+++ b/spec/frontend/design_management/components/design_scaler_spec.js
@@ -0,0 +1,67 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignScaler from '~/design_management/components/design_scaler.vue';
+
+describe('Design management design scaler component', () => {
+ let wrapper;
+
+ function createComponent(propsData, data = {}) {
+ wrapper = shallowMount(DesignScaler, {
+ propsData,
+ });
+ wrapper.setData(data);
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const getButton = type => {
+ const buttonTypeOrder = ['minus', 'reset', 'plus'];
+ const buttons = wrapper.findAll('button');
+ return buttons.at(buttonTypeOrder.indexOf(type));
+ };
+
+ it('emits @scale event when "plus" button clicked', () => {
+ createComponent();
+
+ getButton('plus').trigger('click');
+ expect(wrapper.emitted('scale')).toEqual([[1.2]]);
+ });
+
+ it('emits @scale event when "reset" button clicked (scale > 1)', () => {
+ createComponent({}, { scale: 1.6 });
+ return wrapper.vm.$nextTick().then(() => {
+ getButton('reset').trigger('click');
+ expect(wrapper.emitted('scale')).toEqual([[1]]);
+ });
+ });
+
+ it('emits @scale event when "minus" button clicked (scale > 1)', () => {
+ createComponent({}, { scale: 1.6 });
+
+ return wrapper.vm.$nextTick().then(() => {
+ getButton('minus').trigger('click');
+ expect(wrapper.emitted('scale')).toEqual([[1.4]]);
+ });
+ });
+
+ it('minus and reset buttons are disabled when scale === 1', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('minus and reset buttons are enabled when scale > 1', () => {
+ createComponent({}, { scale: 1.2 });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('plus button is disabled when scale === 2', () => {
+ createComponent({}, { scale: 2 });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/image_spec.js b/spec/frontend/design_management/components/image_spec.js
new file mode 100644
index 00000000000..52d60b04a8a
--- /dev/null
+++ b/spec/frontend/design_management/components/image_spec.js
@@ -0,0 +1,133 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import DesignImage from '~/design_management/components/image.vue';
+
+describe('Design management large image component', () => {
+ let wrapper;
+
+ function createComponent(propsData, data = {}) {
+ wrapper = shallowMount(DesignImage, {
+ propsData,
+ });
+ wrapper.setData(data);
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders loading state', () => {
+ createComponent({
+ isLoading: true,
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders image', () => {
+ createComponent({
+ isLoading: false,
+ image: 'test.jpg',
+ name: 'test',
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('sets correct classes and styles if imageStyle is set', () => {
+ createComponent(
+ {
+ isLoading: false,
+ image: 'test.jpg',
+ name: 'test',
+ },
+ {
+ imageStyle: {
+ width: '100px',
+ height: '100px',
+ },
+ },
+ );
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders media broken icon on error', () => {
+ createComponent({
+ isLoading: false,
+ image: 'test.jpg',
+ name: 'test',
+ });
+
+ const image = wrapper.find('img');
+ image.trigger('error');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(image.isVisible()).toBe(false);
+ expect(wrapper.find(GlIcon).element).toMatchSnapshot();
+ });
+ });
+
+ describe('zoom', () => {
+ const baseImageWidth = 100;
+ const baseImageHeight = 100;
+
+ beforeEach(() => {
+ createComponent(
+ {
+ isLoading: false,
+ image: 'test.jpg',
+ name: 'test',
+ },
+ {
+ imageStyle: {
+ width: `${baseImageWidth}px`,
+ height: `${baseImageHeight}px`,
+ },
+ baseImageSize: {
+ width: baseImageWidth,
+ height: baseImageHeight,
+ },
+ },
+ );
+
+ jest.spyOn(wrapper.vm.$refs.contentImg, 'offsetWidth', 'get').mockReturnValue(baseImageWidth);
+ jest
+ .spyOn(wrapper.vm.$refs.contentImg, 'offsetHeight', 'get')
+ .mockReturnValue(baseImageHeight);
+ });
+
+ it('emits @resize event on zoom', () => {
+ const zoomAmount = 2;
+ wrapper.vm.zoom(zoomAmount);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('resize')).toEqual([
+ [{ width: baseImageWidth * zoomAmount, height: baseImageHeight * zoomAmount }],
+ ]);
+ });
+ });
+
+ it('emits @resize event with base image size when scale=1', () => {
+ wrapper.vm.zoom(1);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('resize')).toEqual([
+ [{ width: baseImageWidth, height: baseImageHeight }],
+ ]);
+ });
+ });
+
+ it('sets image style when zoomed', () => {
+ const zoomAmount = 2;
+ wrapper.vm.zoom(zoomAmount);
+ expect(wrapper.vm.imageStyle).toEqual({
+ width: `${baseImageWidth * zoomAmount}px`,
+ height: `${baseImageHeight * zoomAmount}px`,
+ });
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
new file mode 100644
index 00000000000..9cd427f6aae
--- /dev/null
+++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
@@ -0,0 +1,472 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management list item component when item appears in view after image is loaded renders media broken icon when image onerror triggered 1`] = `
+<gl-icon-stub
+ class="text-secondary"
+ name="media-broken"
+ size="32"
+/>
+`;
+
+exports[`Design management list item component with no notes renders item with correct status icon for creation event 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <div
+ class="design-event position-absolute"
+ >
+ <span
+ aria-label="Added in this version"
+ title="Added in this version"
+ >
+ <icon-stub
+ class="text-success-500"
+ name="file-addition-solid"
+ size="18"
+ />
+ </span>
+ </div>
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <!---->
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders item with correct status icon for deletion event 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <div
+ class="design-event position-absolute"
+ >
+ <span
+ aria-label="Deleted in this version"
+ title="Deleted in this version"
+ >
+ <icon-stub
+ class="text-danger-500"
+ name="file-deletion-solid"
+ size="18"
+ />
+ </span>
+ </div>
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <!---->
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders item with correct status icon for modification event 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <div
+ class="design-event position-absolute"
+ >
+ <span
+ aria-label="Modified in this version"
+ title="Modified in this version"
+ >
+ <icon-stub
+ class="text-primary-500"
+ name="file-modified-solid"
+ size="18"
+ />
+ </span>
+ </div>
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <!---->
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders item with no status icon for none event 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <!---->
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <!---->
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with no notes renders loading spinner when isUploading is true 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <!---->
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <gl-loading-icon-stub
+ color="orange"
+ label="Loading"
+ size="md"
+ />
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ style="display: none;"
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <!---->
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with notes renders item with multiple comments 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <!---->
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <div
+ class="ml-auto d-flex align-items-center text-secondary"
+ >
+ <icon-stub
+ class="ml-1"
+ name="comments"
+ size="16"
+ />
+
+ <span
+ aria-label="2 comments"
+ class="ml-1"
+ >
+
+ 2
+
+ </span>
+ </div>
+ </div>
+</router-link-stub>
+`;
+
+exports[`Design management list item component with notes renders item with single comment 1`] = `
+<router-link-stub
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ to="[object Object]"
+>
+ <div
+ class="card-body p-0 d-flex-center overflow-hidden position-relative"
+ >
+ <!---->
+
+ <gl-intersection-observer-stub
+ options="[object Object]"
+ >
+ <!---->
+
+ <img
+ alt="test"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ src=""
+ />
+ </gl-intersection-observer-stub>
+ </div>
+
+ <div
+ class="card-footer d-flex w-100"
+ >
+ <div
+ class="d-flex flex-column str-truncated-100"
+ >
+ <span
+ class="bold str-truncated-100"
+ data-qa-selector="design_file_name"
+ >
+ test
+ </span>
+
+ <span
+ class="str-truncated-100"
+ >
+
+ Updated
+ <timeago-stub
+ cssclass=""
+ time="01-01-2019"
+ tooltipplacement="bottom"
+ />
+ </span>
+ </div>
+
+ <div
+ class="ml-auto d-flex align-items-center text-secondary"
+ >
+ <icon-stub
+ class="ml-1"
+ name="comments"
+ size="16"
+ />
+
+ <span
+ aria-label="1 comment"
+ class="ml-1"
+ >
+
+ 1
+
+ </span>
+ </div>
+ </div>
+</router-link-stub>
+`;
diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js
new file mode 100644
index 00000000000..705b532454f
--- /dev/null
+++ b/spec/frontend/design_management/components/list/item_spec.js
@@ -0,0 +1,168 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
+import VueRouter from 'vue-router';
+import Item from '~/design_management/components/list/item.vue';
+
+const localVue = createLocalVue();
+localVue.use(VueRouter);
+const router = new VueRouter();
+
+// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent
+const DESIGN_VERSION_EVENT = {
+ CREATION: 'CREATION',
+ DELETION: 'DELETION',
+ MODIFICATION: 'MODIFICATION',
+ NO_CHANGE: 'NONE',
+};
+
+describe('Design management list item component', () => {
+ let wrapper;
+
+ function createComponent({
+ notesCount = 0,
+ event = DESIGN_VERSION_EVENT.NO_CHANGE,
+ isUploading = false,
+ imageLoading = false,
+ } = {}) {
+ wrapper = shallowMount(Item, {
+ localVue,
+ router,
+ propsData: {
+ id: 1,
+ filename: 'test',
+ image: 'http://via.placeholder.com/300',
+ isUploading,
+ event,
+ notesCount,
+ updatedAt: '01-01-2019',
+ },
+ data() {
+ return {
+ imageLoading,
+ };
+ },
+ stubs: ['router-link'],
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when item is not in view', () => {
+ it('image is not rendered', () => {
+ createComponent();
+
+ const image = wrapper.find('img');
+ expect(image.attributes('src')).toBe('');
+ });
+ });
+
+ describe('when item appears in view', () => {
+ let image;
+ let glIntersectionObserver;
+
+ beforeEach(() => {
+ createComponent();
+ image = wrapper.find('img');
+ glIntersectionObserver = wrapper.find(GlIntersectionObserver);
+
+ glIntersectionObserver.vm.$emit('appear');
+ return wrapper.vm.$nextTick();
+ });
+
+ describe('before image is loaded', () => {
+ it('renders loading spinner', () => {
+ expect(wrapper.find(GlLoadingIcon)).toExist();
+ });
+ });
+
+ describe('after image is loaded', () => {
+ beforeEach(() => {
+ image.trigger('load');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders an image', () => {
+ expect(image.attributes('src')).toBe('http://via.placeholder.com/300');
+ expect(image.isVisible()).toBe(true);
+ });
+
+ it('renders media broken icon when image onerror triggered', () => {
+ image.trigger('error');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(image.isVisible()).toBe(false);
+ expect(wrapper.find(GlIcon).element).toMatchSnapshot();
+ });
+ });
+
+ describe('when imageV432x230 and image provided', () => {
+ it('renders imageV432x230 image', () => {
+ const mockSrc = 'mock-imageV432x230-url';
+ wrapper.setProps({ imageV432x230: mockSrc });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(image.attributes('src')).toBe(mockSrc);
+ });
+ });
+ });
+
+ describe('when image disappears from view and then reappears', () => {
+ beforeEach(() => {
+ glIntersectionObserver.vm.$emit('appear');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders an image', () => {
+ expect(image.isVisible()).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('with notes', () => {
+ it('renders item with single comment', () => {
+ createComponent({ notesCount: 1 });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with multiple comments', () => {
+ createComponent({ notesCount: 2 });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('with no notes', () => {
+ it('renders item with no status icon for none event', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with correct status icon for modification event', () => {
+ createComponent({ event: DESIGN_VERSION_EVENT.MODIFICATION });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with correct status icon for deletion event', () => {
+ createComponent({ event: DESIGN_VERSION_EVENT.DELETION });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders item with correct status icon for creation event', () => {
+ createComponent({ event: DESIGN_VERSION_EVENT.CREATION });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders loading spinner when isUploading is true', () => {
+ createComponent({ isUploading: true });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
new file mode 100644
index 00000000000..e55cff8de3d
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
@@ -0,0 +1,61 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management toolbar component renders design and updated data 1`] = `
+<header
+ class="d-flex p-2 bg-white align-items-center js-design-header"
+>
+ <a
+ aria-label="Go back to designs"
+ class="mr-3 text-plain d-flex justify-content-center align-items-center"
+ >
+ <icon-stub
+ name="close"
+ size="18"
+ />
+ </a>
+
+ <div
+ class="overflow-hidden d-flex align-items-center"
+ >
+ <h2
+ class="m-0 str-truncated-100 gl-font-base"
+ >
+ test.jpg
+ </h2>
+
+ <small
+ class="text-secondary"
+ >
+ Updated 1 hour ago by Test Name
+ </small>
+ </div>
+
+ <pagination-stub
+ class="ml-auto flex-shrink-0"
+ id="1"
+ />
+
+ <gl-deprecated-button-stub
+ class="mr-2"
+ href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d"
+ size="md"
+ variant="secondary"
+ >
+ <icon-stub
+ name="download"
+ size="18"
+ />
+ </gl-deprecated-button-stub>
+
+ <delete-button-stub
+ buttonclass=""
+ buttonvariant="danger"
+ hasselecteddesigns="true"
+ >
+ <icon-stub
+ name="remove"
+ size="18"
+ />
+ </delete-button-stub>
+</header>
+`;
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap
new file mode 100644
index 00000000000..08662a04f15
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap
@@ -0,0 +1,28 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management pagination button component disables button when no design is passed 1`] = `
+<router-link-stub
+ aria-label="Test title"
+ class="btn btn-default disabled"
+ disabled="true"
+ to="[object Object]"
+>
+ <icon-stub
+ name="angle-right"
+ size="16"
+ />
+</router-link-stub>
+`;
+
+exports[`Design management pagination button component renders router-link 1`] = `
+<router-link-stub
+ aria-label="Test title"
+ class="btn btn-default"
+ to="[object Object]"
+>
+ <icon-stub
+ name="angle-right"
+ size="16"
+ />
+</router-link-stub>
+`;
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap
new file mode 100644
index 00000000000..0197b4bff79
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap
@@ -0,0 +1,29 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management pagination component hides components when designs are empty 1`] = `<!---->`;
+
+exports[`Design management pagination component renders pagination buttons 1`] = `
+<div
+ class="d-flex align-items-center"
+>
+
+ 0 of 2
+
+ <div
+ class="btn-group ml-3 mr-3"
+ >
+ <pagination-button-stub
+ class="js-previous-design"
+ iconname="angle-left"
+ title="Go to previous design"
+ />
+
+ <pagination-button-stub
+ class="js-next-design"
+ design="[object Object]"
+ iconname="angle-right"
+ title="Go to next design"
+ />
+ </div>
+</div>
+`;
diff --git a/spec/frontend/design_management/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js
new file mode 100644
index 00000000000..2910b2f62ba
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/index_spec.js
@@ -0,0 +1,123 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueRouter from 'vue-router';
+import Toolbar from '~/design_management/components/toolbar/index.vue';
+import DeleteButton from '~/design_management/components/delete_button.vue';
+import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
+import { GlDeprecatedButton } from '@gitlab/ui';
+
+const localVue = createLocalVue();
+localVue.use(VueRouter);
+const router = new VueRouter();
+
+const RouterLinkStub = {
+ props: {
+ to: {
+ type: Object,
+ },
+ },
+ render(createElement) {
+ return createElement('a', {}, this.$slots.default);
+ },
+};
+
+describe('Design management toolbar component', () => {
+ let wrapper;
+
+ function createComponent(isLoading = false, createDesign = true, props) {
+ const updatedAt = new Date();
+ updatedAt.setHours(updatedAt.getHours() - 1);
+
+ wrapper = shallowMount(Toolbar, {
+ localVue,
+ router,
+ propsData: {
+ id: '1',
+ isLatestVersion: true,
+ isLoading,
+ isDeleting: false,
+ filename: 'test.jpg',
+ updatedAt: updatedAt.toString(),
+ updatedBy: {
+ name: 'Test Name',
+ },
+ image: '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d',
+ ...props,
+ },
+ stubs: {
+ 'router-link': RouterLinkStub,
+ },
+ });
+
+ wrapper.setData({
+ permissions: {
+ createDesign,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders design and updated data', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('links back to designs list', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ const link = wrapper.find('a');
+
+ expect(link.props('to')).toEqual({
+ name: DESIGNS_ROUTE_NAME,
+ query: {
+ version: undefined,
+ },
+ });
+ });
+ });
+
+ it('renders delete button on latest designs version with logged in user', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DeleteButton).exists()).toBe(true);
+ });
+ });
+
+ it('does not render delete button on non-latest version', () => {
+ createComponent(false, true, { isLatestVersion: false });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DeleteButton).exists()).toBe(false);
+ });
+ });
+
+ it('does not render delete button when user is not logged in', () => {
+ createComponent(false, false);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DeleteButton).exists()).toBe(false);
+ });
+ });
+
+ it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns');
+ expect(wrapper.emitted().delete).toBeTruthy();
+ });
+ });
+
+ it('renders download button with correct link', () => {
+ expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe(
+ '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d',
+ );
+ });
+});
diff --git a/spec/frontend/design_management/components/toolbar/pagination_button_spec.js b/spec/frontend/design_management/components/toolbar/pagination_button_spec.js
new file mode 100644
index 00000000000..b7df201795b
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/pagination_button_spec.js
@@ -0,0 +1,61 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueRouter from 'vue-router';
+import PaginationButton from '~/design_management/components/toolbar/pagination_button.vue';
+import { DESIGN_ROUTE_NAME } from '~/design_management/router/constants';
+
+const localVue = createLocalVue();
+localVue.use(VueRouter);
+const router = new VueRouter();
+
+describe('Design management pagination button component', () => {
+ let wrapper;
+
+ function createComponent(design = null) {
+ wrapper = shallowMount(PaginationButton, {
+ localVue,
+ router,
+ propsData: {
+ design,
+ title: 'Test title',
+ iconName: 'angle-right',
+ },
+ stubs: ['router-link'],
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('disables button when no design is passed', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders router-link', () => {
+ createComponent({ id: '2' });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('designLink', () => {
+ it('returns empty link when design is null', () => {
+ createComponent();
+
+ expect(wrapper.vm.designLink).toEqual({});
+ });
+
+ it('returns design link', () => {
+ createComponent({ id: '2', filename: 'test' });
+
+ wrapper.vm.$router.replace('/root/test-project/issues/1/designs/test?version=1');
+
+ expect(wrapper.vm.designLink).toEqual({
+ name: DESIGN_ROUTE_NAME,
+ params: { id: 'test' },
+ query: { version: '1' },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/toolbar/pagination_spec.js b/spec/frontend/design_management/components/toolbar/pagination_spec.js
new file mode 100644
index 00000000000..db5a36dadf6
--- /dev/null
+++ b/spec/frontend/design_management/components/toolbar/pagination_spec.js
@@ -0,0 +1,79 @@
+/* global Mousetrap */
+import 'mousetrap';
+import { shallowMount } from '@vue/test-utils';
+import Pagination from '~/design_management/components/toolbar/pagination.vue';
+import { DESIGN_ROUTE_NAME } from '~/design_management/router/constants';
+
+const push = jest.fn();
+const $router = {
+ push,
+};
+
+const $route = {
+ path: '/designs/design-2',
+ query: {},
+};
+
+describe('Design management pagination component', () => {
+ let wrapper;
+
+ function createComponent() {
+ wrapper = shallowMount(Pagination, {
+ propsData: {
+ id: '2',
+ },
+ mocks: {
+ $router,
+ $route,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('hides components when designs are empty', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders pagination buttons', () => {
+ wrapper.setData({
+ designs: [{ id: '1' }, { id: '2' }],
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('keyboard buttons navigation', () => {
+ beforeEach(() => {
+ wrapper.setData({
+ designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }],
+ });
+ });
+
+ it('routes to previous design on Left button', () => {
+ Mousetrap.trigger('left');
+ expect(push).toHaveBeenCalledWith({
+ name: DESIGN_ROUTE_NAME,
+ params: { id: '1' },
+ query: {},
+ });
+ });
+
+ it('routes to next design on Right button', () => {
+ Mousetrap.trigger('right');
+ expect(push).toHaveBeenCalledWith({
+ name: DESIGN_ROUTE_NAME,
+ params: { id: '3' },
+ query: {},
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
new file mode 100644
index 00000000000..185bf4a48f7
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
@@ -0,0 +1,79 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management upload button component renders inverted upload design button 1`] = `
+<div
+ isinverted="true"
+>
+ <gl-deprecated-button-stub
+ size="md"
+ title="Adding a design with the same filename replaces the file in a new version."
+ variant="success"
+ >
+
+ Add designs
+
+ <!---->
+ </gl-deprecated-button-stub>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+</div>
+`;
+
+exports[`Design management upload button component renders loading icon 1`] = `
+<div>
+ <gl-deprecated-button-stub
+ disabled="true"
+ size="md"
+ title="Adding a design with the same filename replaces the file in a new version."
+ variant="success"
+ >
+
+ Add designs
+
+ <gl-loading-icon-stub
+ class="ml-1"
+ color="orange"
+ inline="true"
+ label="Loading"
+ size="sm"
+ />
+ </gl-deprecated-button-stub>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+</div>
+`;
+
+exports[`Design management upload button component renders upload design button 1`] = `
+<div>
+ <gl-deprecated-button-stub
+ size="md"
+ title="Adding a design with the same filename replaces the file in a new version."
+ variant="success"
+ >
+
+ Add designs
+
+ <!---->
+ </gl-deprecated-button-stub>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+</div>
+`;
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap
new file mode 100644
index 00000000000..0737b9729a2
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap
@@ -0,0 +1,455 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management dropzone component when dragging renders correct template when drag event contains files 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style=""
+ >
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style=""
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style=""
+ >
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style=""
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style=""
+ >
+ <div
+ class="mw-50 text-center"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style=""
+ >
+ <div
+ class="mw-50 text-center"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style="display: none;"
+ >
+ <div
+ class="mw-50 text-center"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ >
+ <div
+ class="d-flex-center flex-column text-center"
+ >
+ <gl-icon-stub
+ class="mb-4"
+ name="doc-new"
+ size="48"
+ />
+
+ <p>
+ <gl-sprintf-stub
+ message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}."
+ />
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/*"
+ class="hide"
+ multiple="multiple"
+ name="design_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style="display: none;"
+ >
+ <div
+ class="mw-50 text-center"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = `
+<div
+ class="w-100 position-relative"
+>
+ <div>
+ dropzone slot
+ </div>
+
+ <transition-stub
+ name="design-dropzone-fade"
+ >
+ <div
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ style="display: none;"
+ >
+ <div
+ class="mw-50 text-center"
+ >
+ <h3>
+ Oh no!
+ </h3>
+
+ <span>
+ You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.
+ </span>
+ </div>
+
+ <div
+ class="mw-50 text-center"
+ style="display: none;"
+ >
+ <h3>
+ Incoming!
+ </h3>
+
+ <span>
+ Drop your designs to start your upload.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
new file mode 100644
index 00000000000..00f1a40dfb2
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
@@ -0,0 +1,111 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design management design version dropdown component renders design version dropdown button 1`] = `
+<gl-dropdown-stub
+ class="design-version-dropdown"
+ issueiid=""
+ projectpath=""
+ text="Showing Latest Version"
+ variant="link"
+>
+ <gl-dropdown-item-stub>
+ <router-link-stub
+ class="d-flex js-version-link"
+ to="[object Object]"
+ >
+ <div
+ class="flex-grow-1 ml-2"
+ >
+ <div>
+ <strong>
+ Version 2
+
+ <span>
+ (latest)
+ </span>
+ </strong>
+ </div>
+ </div>
+
+ <i
+ class="fa fa-check pull-right"
+ />
+ </router-link-stub>
+ </gl-dropdown-item-stub>
+ <gl-dropdown-item-stub>
+ <router-link-stub
+ class="d-flex js-version-link"
+ to="[object Object]"
+ >
+ <div
+ class="flex-grow-1 ml-2"
+ >
+ <div>
+ <strong>
+ Version 1
+
+ <!---->
+ </strong>
+ </div>
+ </div>
+
+ <!---->
+ </router-link-stub>
+ </gl-dropdown-item-stub>
+</gl-dropdown-stub>
+`;
+
+exports[`Design management design version dropdown component renders design version list 1`] = `
+<gl-dropdown-stub
+ class="design-version-dropdown"
+ issueiid=""
+ projectpath=""
+ text="Showing Latest Version"
+ variant="link"
+>
+ <gl-dropdown-item-stub>
+ <router-link-stub
+ class="d-flex js-version-link"
+ to="[object Object]"
+ >
+ <div
+ class="flex-grow-1 ml-2"
+ >
+ <div>
+ <strong>
+ Version 2
+
+ <span>
+ (latest)
+ </span>
+ </strong>
+ </div>
+ </div>
+
+ <i
+ class="fa fa-check pull-right"
+ />
+ </router-link-stub>
+ </gl-dropdown-item-stub>
+ <gl-dropdown-item-stub>
+ <router-link-stub
+ class="d-flex js-version-link"
+ to="[object Object]"
+ >
+ <div
+ class="flex-grow-1 ml-2"
+ >
+ <div>
+ <strong>
+ Version 1
+
+ <!---->
+ </strong>
+ </div>
+ </div>
+
+ <!---->
+ </router-link-stub>
+ </gl-dropdown-item-stub>
+</gl-dropdown-stub>
+`;
diff --git a/spec/frontend/design_management/components/upload/button_spec.js b/spec/frontend/design_management/components/upload/button_spec.js
new file mode 100644
index 00000000000..c0a9693dc37
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/button_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import UploadButton from '~/design_management/components/upload/button.vue';
+
+describe('Design management upload button component', () => {
+ let wrapper;
+
+ function createComponent(isSaving = false, isInverted = false) {
+ wrapper = shallowMount(UploadButton, {
+ propsData: {
+ isSaving,
+ isInverted,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders upload design button', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders inverted upload design button', () => {
+ createComponent(false, true);
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders loading icon', () => {
+ createComponent(true);
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('onFileUploadChange', () => {
+ it('emits upload event', () => {
+ createComponent();
+
+ wrapper.vm.onFileUploadChange({ target: { files: 'test' } });
+
+ expect(wrapper.emitted().upload[0]).toEqual(['test']);
+ });
+ });
+
+ describe('openFileUpload', () => {
+ it('triggers click on input', () => {
+ createComponent();
+
+ const clickSpy = jest.spyOn(wrapper.find('input').element, 'click');
+
+ wrapper.vm.openFileUpload();
+
+ expect(clickSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/upload/design_dropzone_spec.js b/spec/frontend/design_management/components/upload/design_dropzone_spec.js
new file mode 100644
index 00000000000..9b86b5b2878
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/design_dropzone_spec.js
@@ -0,0 +1,132 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
+
+describe('Design management dropzone component', () => {
+ let wrapper;
+
+ const mockDragEvent = ({ types = ['Files'], files = [] }) => {
+ return { dataTransfer: { types, files } };
+ };
+
+ const findDropzoneCard = () => wrapper.find('.design-dropzone-card');
+
+ function createComponent({ slots = {}, data = {} } = {}) {
+ wrapper = shallowMount(DesignDropzone, {
+ slots,
+ data() {
+ return data;
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when slot provided', () => {
+ it('renders dropzone with slot content', () => {
+ createComponent({
+ slots: {
+ default: ['<div>dropzone slot</div>'],
+ },
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('when no slot provided', () => {
+ it('renders default dropzone card', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('triggers click event on file input element when clicked', () => {
+ createComponent();
+ const clickSpy = jest.spyOn(wrapper.find('input').element, 'click');
+
+ findDropzoneCard().trigger('click');
+ expect(clickSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('when dragging', () => {
+ it.each`
+ description | eventPayload
+ ${'is empty'} | ${{}}
+ ${'contains text'} | ${mockDragEvent({ types: ['text'] })}
+ ${'contains files and text'} | ${mockDragEvent({ types: ['Files', 'text'] })}
+ ${'contains files'} | ${mockDragEvent({ types: ['Files'] })}
+ `('renders correct template when drag event $description', ({ eventPayload }) => {
+ createComponent();
+
+ wrapper.trigger('dragenter', eventPayload);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders correct template when dragging stops', () => {
+ createComponent();
+
+ wrapper.trigger('dragenter');
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ wrapper.trigger('dragleave');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('when dropping', () => {
+ it('emits upload event', () => {
+ createComponent();
+ const mockFile = { name: 'test', type: 'image/jpg' };
+ const mockEvent = mockDragEvent({ files: [mockFile] });
+
+ wrapper.trigger('dragenter', mockEvent);
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ wrapper.trigger('drop', mockEvent);
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(wrapper.emitted().change[0]).toEqual([[mockFile]]);
+ });
+ });
+ });
+
+ describe('ondrop', () => {
+ const mockData = { dragCounter: 1, isDragDataValid: true };
+
+ describe('when drag data is valid', () => {
+ it('emits upload event for valid files', () => {
+ createComponent({ data: mockData });
+
+ const mockFile = { type: 'image/jpg' };
+ const mockEvent = mockDragEvent({ files: [mockFile] });
+
+ wrapper.vm.ondrop(mockEvent);
+ expect(wrapper.emitted().change[0]).toEqual([[mockFile]]);
+ });
+
+ it('calls createFlash when files are invalid', () => {
+ createComponent({ data: mockData });
+
+ const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] });
+
+ wrapper.vm.ondrop(mockEvent);
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
new file mode 100644
index 00000000000..7521b9fad2a
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js
@@ -0,0 +1,114 @@
+import { shallowMount } from '@vue/test-utils';
+import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import mockAllVersions from './mock_data/all_versions';
+
+const LATEST_VERSION_ID = 3;
+const PREVIOUS_VERSION_ID = 2;
+
+const designRouteFactory = versionId => ({
+ path: `/designs?version=${versionId}`,
+ query: {
+ version: `${versionId}`,
+ },
+});
+
+const MOCK_ROUTE = {
+ path: '/designs',
+ query: {},
+};
+
+describe('Design management design version dropdown component', () => {
+ let wrapper;
+
+ function createComponent({ maxVersions = -1, $route = MOCK_ROUTE } = {}) {
+ wrapper = shallowMount(DesignVersionDropdown, {
+ propsData: {
+ projectPath: '',
+ issueIid: '',
+ },
+ mocks: {
+ $route,
+ },
+ stubs: ['router-link'],
+ });
+
+ wrapper.setData({
+ allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions,
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findVersionLink = index => wrapper.findAll('.js-version-link').at(index);
+
+ it('renders design version dropdown button', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ it('renders design version list', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('selected version name', () => {
+ it('has "latest" on most recent version item', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findVersionLink(0).text()).toContain('latest');
+ });
+ });
+ });
+
+ describe('versions list', () => {
+ it('displays latest version text by default', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version');
+ });
+ });
+
+ it('displays latest version text when only 1 version is present', () => {
+ createComponent({ maxVersions: 1 });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version');
+ });
+ });
+
+ it('displays version text when the current version is not the latest', () => {
+ createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe(`Showing Version #1`);
+ });
+ });
+
+ it('displays latest version text when the current version is the latest', () => {
+ createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing Latest Version');
+ });
+ });
+
+ it('should have the same length as apollo query', () => {
+ createComponent();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/upload/mock_data/all_versions.js b/spec/frontend/design_management/components/upload/mock_data/all_versions.js
new file mode 100644
index 00000000000..e76bbd261bd
--- /dev/null
+++ b/spec/frontend/design_management/components/upload/mock_data/all_versions.js
@@ -0,0 +1,14 @@
+export default [
+ {
+ node: {
+ id: 'gid://gitlab/DesignManagement::Version/3',
+ sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55',
+ },
+ },
+ {
+ node: {
+ id: 'gid://gitlab/DesignManagement::Version/2',
+ sha: '5b063fef0cd7213b312db65b30e24f057df21b20',
+ },
+ },
+];