diff options
Diffstat (limited to 'spec/frontend/design_management')
17 files changed, 822 insertions, 231 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 index 4828e8cb3c2..4c848256e5b 100644 --- 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 @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Design discussions component should match the snapshot of note when repositioning 1`] = ` +exports[`Design note pin component should match the snapshot of note when repositioning 1`] = ` <button aria-label="Comment form position" - class="position-absolute btn-transparent comment-indicator" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center btn-transparent comment-indicator" style="left: 10px; top: 10px; cursor: move;" type="button" > @@ -14,10 +14,10 @@ exports[`Design discussions component should match the snapshot of note when rep </button> `; -exports[`Design discussions component should match the snapshot of note with index 1`] = ` +exports[`Design note pin 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" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center js-image-badge badge badge-pill" style="left: 10px; top: 10px;" type="button" > @@ -27,10 +27,10 @@ exports[`Design discussions component should match the snapshot of note with ind </button> `; -exports[`Design discussions component should match the snapshot of note without index 1`] = ` +exports[`Design note pin component should match the snapshot of note without index 1`] = ` <button aria-label="Comment form position" - class="position-absolute btn-transparent comment-indicator" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center btn-transparent comment-indicator" style="left: 10px; top: 10px;" type="button" > diff --git a/spec/frontend/design_management/components/design_note_pin_spec.js b/spec/frontend/design_management/components/design_note_pin_spec.js index 4f7260b1363..4e045b58a35 100644 --- a/spec/frontend/design_management/components/design_note_pin_spec.js +++ b/spec/frontend/design_management/components/design_note_pin_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import DesignNotePin from '~/design_management/components/design_note_pin.vue'; -describe('Design discussions component', () => { +describe('Design note pin component', () => { let wrapper; function createComponent(propsData = {}) { @@ -26,7 +26,7 @@ describe('Design discussions component', () => { }); it('should match the snapshot of note with index', () => { - createComponent({ label: '1' }); + createComponent({ label: 1 }); expect(wrapper.element).toMatchSnapshot(); }); 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 index e071274cc81..b55bacb6fc5 100644 --- 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 @@ -50,12 +50,18 @@ exports[`Design note component should match the snapshot 1`] = ` </span> </div> - <!----> + <div + class="gl-display-flex" + > + + <!----> + </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/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index b16b26ff82f..557f53e864f 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -1,16 +1,33 @@ -import { shallowMount } from '@vue/test-utils'; -import { ApolloMutation } from 'vue-apollo'; +import { mount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import notes from '../../mock_data/notes'; 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 toggleResolveDiscussionMutation from '~/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue'; + +const discussion = { + id: '0', + resolved: false, + resolvable: true, + notes, +}; describe('Design discussions component', () => { let wrapper; + const findDesignNotes = () => wrapper.findAll(DesignNote); const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder); const findReplyForm = () => wrapper.find(DesignReplyForm); + const findRepliesWidget = () => wrapper.find(ToggleRepliesWidget); + const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]'); + const findResolveIcon = () => wrapper.find('[data-testid="resolve-icon"]'); + const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]'); + const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]'); const mutationVariables = { mutation: createNoteMutation, @@ -29,22 +46,14 @@ describe('Design discussions component', () => { }; function createComponent(props = {}, data = {}) { - wrapper = shallowMount(DesignDiscussion, { + wrapper = mount(DesignDiscussion, { propsData: { - discussion: { - id: '0', - notes: [ - { - id: '1', - }, - { - id: '2', - }, - ], - }, + resolvedDiscussionsExpanded: true, + discussion, noteableId: 'noteable-id', designId: 'design-id', discussionIndex: 1, + discussionWithOpenForm: '', ...props, }, data() { @@ -52,11 +61,12 @@ describe('Design discussions component', () => { ...data, }; }, - stubs: { - ReplyPlaceholder, - ApolloMutation, + mocks: { + $apollo, + $route: { + hash: '#note_1', + }, }, - mocks: { $apollo }, }); } @@ -64,19 +74,147 @@ describe('Design discussions component', () => { wrapper.destroy(); }); - it('renders correct amount of discussion notes', () => { - createComponent(); - expect(wrapper.findAll(DesignNote)).toHaveLength(2); + describe('when discussion is not resolvable', () => { + beforeEach(() => { + createComponent({ + discussion: { + ...discussion, + resolvable: false, + }, + }); + }); + + it('does not render an icon to resolve a thread', () => { + expect(findResolveIcon().exists()).toBe(false); + }); + + it('does not render a checkbox in reply form', () => { + findReplyPlaceholder().vm.$emit('onMouseDown'); + + return wrapper.vm.$nextTick().then(() => { + expect(findResolveCheckbox().exists()).toBe(false); + }); + }); }); - it('renders reply placeholder by default', () => { - createComponent(); - expect(findReplyPlaceholder().exists()).toBe(true); + describe('when discussion is unresolved', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders correct amount of discussion notes', () => { + expect(findDesignNotes()).toHaveLength(2); + expect(findDesignNotes().wrappers.every(w => w.isVisible())).toBe(true); + }); + + it('renders reply placeholder', () => { + expect(findReplyPlaceholder().isVisible()).toBe(true); + }); + + it('does not render toggle replies widget', () => { + expect(findRepliesWidget().exists()).toBe(false); + }); + + it('renders a correct icon to resolve a thread', () => { + expect(findResolveIcon().props('name')).toBe('check-circle'); + }); + + it('renders a checkbox with Resolve thread text in reply form', () => { + findReplyPlaceholder().vm.$emit('onClick'); + wrapper.setProps({ discussionWithOpenForm: discussion.id }); + + return wrapper.vm.$nextTick().then(() => { + expect(findResolveCheckbox().text()).toBe('Resolve thread'); + }); + }); + + it('does not render resolved message', () => { + expect(findResolvedMessage().exists()).toBe(false); + }); + }); + + describe('when discussion is resolved', () => { + beforeEach(() => { + createComponent({ + discussion: { + ...discussion, + resolved: true, + resolvedBy: notes[0].author, + resolvedAt: '2020-05-08T07:10:45Z', + }, + }); + }); + + it('shows only the first note', () => { + expect( + findDesignNotes() + .at(0) + .isVisible(), + ).toBe(true); + expect( + findDesignNotes() + .at(1) + .isVisible(), + ).toBe(false); + }); + + it('renders resolved message', () => { + expect(findResolvedMessage().exists()).toBe(true); + }); + + it('does not show renders reply placeholder', () => { + expect(findReplyPlaceholder().isVisible()).toBe(false); + }); + + it('renders toggle replies widget with correct props', () => { + expect(findRepliesWidget().exists()).toBe(true); + expect(findRepliesWidget().props()).toEqual({ + collapsed: true, + replies: notes.slice(1), + }); + }); + + it('renders a correct icon to resolve a thread', () => { + expect(findResolveIcon().props('name')).toBe('check-circle-filled'); + }); + + describe('when replies are expanded', () => { + beforeEach(() => { + findRepliesWidget().vm.$emit('toggle'); + return wrapper.vm.$nextTick(); + }); + + it('renders replies widget with collapsed prop equal to false', () => { + expect(findRepliesWidget().props('collapsed')).toBe(false); + }); + + it('renders the second note', () => { + expect( + findDesignNotes() + .at(1) + .isVisible(), + ).toBe(true); + }); + + it('renders a reply placeholder', () => { + expect(findReplyPlaceholder().isVisible()).toBe(true); + }); + + it('renders a checkbox with Unresolve thread text in reply form', () => { + findReplyPlaceholder().vm.$emit('onClick'); + wrapper.setProps({ discussionWithOpenForm: discussion.id }); + + return wrapper.vm.$nextTick().then(() => { + expect(findResolveCheckbox().text()).toBe('Unresolve thread'); + }); + }); + }); }); it('hides reply placeholder and opens form on placeholder click', () => { createComponent(); - findReplyPlaceholder().trigger('click'); + findReplyPlaceholder().vm.$emit('onClick'); + wrapper.setProps({ discussionWithOpenForm: discussion.id }); return wrapper.vm.$nextTick().then(() => { expect(findReplyPlaceholder().exists()).toBe(false); @@ -85,7 +223,10 @@ describe('Design discussions component', () => { }); it('calls mutation on submitting form and closes the form', () => { - createComponent({}, { discussionComment: 'test', isFormRendered: true }); + createComponent( + { discussionWithOpenForm: discussion.id }, + { discussionComment: 'test', isFormRendered: true }, + ); findReplyForm().vm.$emit('submitForm'); expect(mutate).toHaveBeenCalledWith(mutationVariables); @@ -100,7 +241,10 @@ describe('Design discussions component', () => { }); it('clears the discussion comment on closing comment form', () => { - createComponent({}, { discussionComment: 'test', isFormRendered: true }); + createComponent( + { discussionWithOpenForm: discussion.id }, + { discussionComment: 'test', isFormRendered: true }, + ); return wrapper.vm .$nextTick() @@ -120,7 +264,7 @@ describe('Design discussions component', () => { {}, { activeDiscussion: { - id: '1', + id: notes[0].id, source: 'pin', }, }, @@ -130,4 +274,45 @@ describe('Design discussions component', () => { true, ); }); + + it('calls toggleResolveDiscussion mutation on resolve thread button click', () => { + createComponent(); + findResolveButton().trigger('click'); + expect(mutate).toHaveBeenCalledWith({ + mutation: toggleResolveDiscussionMutation, + variables: { + id: discussion.id, + resolve: true, + }, + }); + return wrapper.vm.$nextTick(() => { + expect(findResolveLoadingIcon().exists()).toBe(true); + }); + }); + + it('calls toggleResolveDiscussion mutation after adding a note if checkbox was checked', () => { + createComponent( + { discussionWithOpenForm: discussion.id }, + { discussionComment: 'test', isFormRendered: true }, + ); + findResolveButton().trigger('click'); + findReplyForm().vm.$emit('submitForm'); + + return mutate().then(() => { + expect(mutate).toHaveBeenCalledWith({ + mutation: toggleResolveDiscussionMutation, + variables: { + id: discussion.id, + resolve: true, + }, + }); + }); + }); + + it('emits openForm event on opening the form', () => { + createComponent(); + findReplyPlaceholder().vm.$emit('onClick'); + + expect(wrapper.emitted('openForm')).toBeTruthy(); + }); }); 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 index 34b8f1f9fa8..16b34f150b8 100644 --- 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 @@ -18,7 +18,7 @@ describe('Design reply form component', () => { const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); const findModal = () => wrapper.find({ ref: 'cancelCommentModal' }); - function createComponent(props = {}) { + function createComponent(props = {}, mountOptions = {}) { wrapper = mount(DesignReplyForm, { propsData: { value: '', @@ -26,6 +26,7 @@ describe('Design reply form component', () => { ...props, }, stubs: { GlModal }, + ...mountOptions, }); } @@ -34,7 +35,8 @@ describe('Design reply form component', () => { }); it('textarea has focus after component mount', () => { - createComponent(); + // We need to attach to document, so that `document.activeElement` is properly set in jsdom + createComponent({}, { attachToDocument: true }); expect(findTextarea().element).toEqual(document.activeElement); }); diff --git a/spec/frontend/design_management/components/design_notes/toggle_replies_widget_spec.js b/spec/frontend/design_management/components/design_notes/toggle_replies_widget_spec.js new file mode 100644 index 00000000000..7eda294d2d3 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/toggle_replies_widget_spec.js @@ -0,0 +1,98 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlButton, GlLink } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue'; +import notes from '../../mock_data/notes'; + +describe('Toggle replies widget component', () => { + let wrapper; + + const findToggleWrapper = () => wrapper.find('[data-testid="toggle-comments-wrapper"]'); + const findIcon = () => wrapper.find(GlIcon); + const findButton = () => wrapper.find(GlButton); + const findAuthorLink = () => wrapper.find(GlLink); + const findTimeAgo = () => wrapper.find(TimeAgoTooltip); + + function createComponent(props = {}) { + wrapper = shallowMount(ToggleRepliesWidget, { + propsData: { + collapsed: true, + replies: notes, + ...props, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when replies are collapsed', () => { + beforeEach(() => { + createComponent(); + }); + + it('should not have expanded class', () => { + expect(findToggleWrapper().classes()).not.toContain('expanded'); + }); + + it('should render chevron-right icon', () => { + expect(findIcon().props('name')).toBe('chevron-right'); + }); + + it('should have replies length on button', () => { + expect(findButton().text()).toBe('2 replies'); + }); + + it('should render a link to the last reply author', () => { + expect(findAuthorLink().exists()).toBe(true); + expect(findAuthorLink().text()).toBe(notes[1].author.name); + expect(findAuthorLink().attributes('href')).toBe(notes[1].author.webUrl); + }); + + it('should render correct time ago tooltip', () => { + expect(findTimeAgo().exists()).toBe(true); + expect(findTimeAgo().props('time')).toBe(notes[1].createdAt); + }); + }); + + describe('when replies are expanded', () => { + beforeEach(() => { + createComponent({ collapsed: false }); + }); + + it('should have expanded class', () => { + expect(findToggleWrapper().classes()).toContain('expanded'); + }); + + it('should render chevron-down icon', () => { + expect(findIcon().props('name')).toBe('chevron-down'); + }); + + it('should have Collapse replies text on button', () => { + expect(findButton().text()).toBe('Collapse replies'); + }); + + it('should not have a link to the last reply author', () => { + expect(findAuthorLink().exists()).toBe(false); + }); + + it('should not render time ago tooltip', () => { + expect(findTimeAgo().exists()).toBe(false); + }); + }); + + it('should emit toggle event on icon click', () => { + createComponent(); + findIcon().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.emitted('toggle')).toHaveLength(1); + }); + + it('should emit toggle event on button click', () => { + createComponent(); + findButton().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.emitted('toggle')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js index 1c9b130aca6..f243323b162 100644 --- a/spec/frontend/design_management/components/design_overlay_spec.js +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -10,16 +10,6 @@ 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'); @@ -43,6 +33,7 @@ describe('Design overlay component', () => { top: '0', left: '0', }, + resolvedDiscussionsExpanded: false, ...props, }, data() { @@ -88,19 +79,46 @@ describe('Design overlay component', () => { }); describe('with notes', () => { - beforeEach(() => { + it('should render only the first note', () => { createComponent({ notes, }); + expect(findAllNotes()).toHaveLength(1); }); - it('should render a correct amount of notes', () => { - expect(findAllNotes()).toHaveLength(notes.length); - }); + describe('with resolved discussions toggle expanded', () => { + beforeEach(() => { + createComponent({ + notes, + resolvedDiscussionsExpanded: true, + }); + }); + + it('should render all notes', () => { + expect(findAllNotes()).toHaveLength(notes.length); + }); + + it('should have set the correct position for each note badge', () => { + expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;'); + expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;'); + }); + + it('should apply resolved class to the resolved note pin', () => { + expect(findSecondBadge().classes()).toContain('resolved'); + }); + + 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', + }, + }); - 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;'); + return wrapper.vm.$nextTick().then(() => { + expect(findSecondBadge().classes()).toContain('inactive'); + }); + }); }); it('should recalculate badges positions on window resize', () => { @@ -144,19 +162,6 @@ describe('Design overlay component', () => { 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', () => { @@ -213,20 +218,32 @@ describe('Design overlay component', () => { }); }); - it('should do nothing if [adminNote] permission is not present', () => { - createComponent({ - dimensions: mockDimensions, - notes: [mockNoteNotAuthorised], - }); + describe('without [adminNote] permission', () => { + const mockNoteNotAuthorised = { + ...notes[0], + userPermissions: { + adminNote: false, + }, + }; - 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;'); + const mockNoteCoordinates = { + x: mockNoteNotAuthorised.position.x, + y: mockNoteNotAuthorised.position.y, + }; + + it('should be unable to move a note', () => { + createComponent({ + dimensions: mockDimensions, + notes: [mockNoteNotAuthorised], + }); + + const badge = findAllNotes().at(0); + return clickAndDragBadge(badge, { ...mockNoteCoordinates }, { x: 20, y: 20 }).then(() => { + // note position should not change after a click-and-drag attempt + expect(findFirstBadge().attributes().style).toContain( + `left: ${mockNoteCoordinates.x}px; top: ${mockNoteCoordinates.y}px;`, + ); + }); }); }); }); diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js index 8a709393d92..7e513182589 100644 --- a/spec/frontend/design_management/components/design_presentation_spec.js +++ b/spec/frontend/design_management/components/design_presentation_spec.js @@ -17,7 +17,13 @@ describe('Design management design presentation component', () => { let wrapper; function createComponent( - { image, imageName, discussions = [], isAnnotating = false } = {}, + { + image, + imageName, + discussions = [], + isAnnotating = false, + resolvedDiscussionsExpanded = false, + } = {}, data = {}, stubs = {}, ) { @@ -27,6 +33,7 @@ describe('Design management design presentation component', () => { imageName, discussions, isAnnotating, + resolvedDiscussionsExpanded, }, stubs, }); diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js new file mode 100644 index 00000000000..e098e7de867 --- /dev/null +++ b/spec/frontend/design_management/components/design_sidebar_spec.js @@ -0,0 +1,236 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlCollapse, GlPopover } from '@gitlab/ui'; +import Cookies from 'js-cookie'; +import DesignSidebar from '~/design_management/components/design_sidebar.vue'; +import Participants from '~/sidebar/components/participants/participants.vue'; +import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; +import design from '../mock_data/design'; +import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql'; + +const updateActiveDiscussionMutationVariables = { + mutation: updateActiveDiscussionMutation, + variables: { + id: design.discussions.nodes[0].notes.nodes[0].id, + source: 'discussion', + }, +}; + +const $route = { + params: { + id: '1', + }, +}; + +const cookieKey = 'hide_design_resolved_comments_popover'; + +const mutate = jest.fn().mockResolvedValue(); + +describe('Design management design sidebar component', () => { + let wrapper; + + const findDiscussions = () => wrapper.findAll(DesignDiscussion); + const findFirstDiscussion = () => findDiscussions().at(0); + const findUnresolvedDiscussions = () => wrapper.findAll('[data-testid="unresolved-discussion"]'); + const findResolvedDiscussions = () => wrapper.findAll('[data-testid="resolved-discussion"]'); + const findParticipants = () => wrapper.find(Participants); + const findCollapsible = () => wrapper.find(GlCollapse); + const findToggleResolvedCommentsButton = () => wrapper.find('[data-testid="resolved-comments"]'); + const findPopover = () => wrapper.find(GlPopover); + const findNewDiscussionDisclaimer = () => + wrapper.find('[data-testid="new-discussion-disclaimer"]'); + + function createComponent(props = {}) { + wrapper = shallowMount(DesignSidebar, { + propsData: { + design, + resolvedDiscussionsExpanded: false, + markdownPreviewPath: '', + ...props, + }, + mocks: { + $route, + $apollo: { + mutate, + }, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders participants', () => { + createComponent(); + + expect(findParticipants().exists()).toBe(true); + }); + + it('passes the correct amount of participants to the Participants component', () => { + createComponent(); + + expect(findParticipants().props('participants')).toHaveLength(1); + }); + + describe('when has no discussions', () => { + beforeEach(() => { + createComponent({ + design: { + ...design, + discussions: { + nodes: [], + }, + }, + }); + }); + + it('does not render discussions', () => { + expect(findDiscussions().exists()).toBe(false); + }); + + it('renders a message about possibility to create a new discussion', () => { + expect(findNewDiscussionDisclaimer().exists()).toBe(true); + }); + }); + + describe('when has discussions', () => { + beforeEach(() => { + Cookies.set(cookieKey, true); + createComponent(); + }); + + it('renders correct amount of unresolved discussions', () => { + expect(findUnresolvedDiscussions()).toHaveLength(1); + }); + + it('renders correct amount of resolved discussions', () => { + expect(findResolvedDiscussions()).toHaveLength(1); + }); + + it('has resolved comments collapsible collapsed', () => { + expect(findCollapsible().attributes('visible')).toBeUndefined(); + }); + + it('emits toggleResolveComments event on resolve comments button click', () => { + findToggleResolvedCommentsButton().vm.$emit('click'); + expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1); + }); + + it('opens a collapsible when resolvedDiscussionsExpanded prop changes to true', () => { + expect(findCollapsible().attributes('visible')).toBeUndefined(); + wrapper.setProps({ + resolvedDiscussionsExpanded: true, + }); + return wrapper.vm.$nextTick().then(() => { + expect(findCollapsible().attributes('visible')).toBe('true'); + }); + }); + + it('does not popover about resolved comments', () => { + expect(findPopover().exists()).toBe(false); + }); + + it('sends a mutation to set an active discussion when clicking on a discussion', () => { + findFirstDiscussion().trigger('click'); + + expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables); + }); + + it('sends a mutation to reset an active discussion when clicking outside of discussion', () => { + wrapper.trigger('click'); + + expect(mutate).toHaveBeenCalledWith({ + ...updateActiveDiscussionMutationVariables, + variables: { id: undefined, source: 'discussion' }, + }); + }); + + it('emits correct event on discussion create note error', () => { + findFirstDiscussion().vm.$emit('createNoteError', 'payload'); + expect(wrapper.emitted('onDesignDiscussionError')).toEqual([['payload']]); + }); + + it('emits correct event on discussion update note error', () => { + findFirstDiscussion().vm.$emit('updateNoteError', 'payload'); + expect(wrapper.emitted('updateNoteError')).toEqual([['payload']]); + }); + + it('emits correct event on discussion resolve error', () => { + findFirstDiscussion().vm.$emit('resolveDiscussionError', 'payload'); + expect(wrapper.emitted('resolveDiscussionError')).toEqual([['payload']]); + }); + + it('changes prop correctly on opening discussion form', () => { + findFirstDiscussion().vm.$emit('openForm', 'some-id'); + + return wrapper.vm.$nextTick().then(() => { + expect(findFirstDiscussion().props('discussionWithOpenForm')).toBe('some-id'); + }); + }); + }); + + describe('when all discussions are resolved', () => { + beforeEach(() => { + createComponent({ + design: { + ...design, + discussions: { + nodes: [ + { + id: 'discussion-id', + replyId: 'discussion-reply-id', + resolved: true, + notes: { + nodes: [ + { + id: 'note-id', + body: '123', + author: { + name: 'Administrator', + username: 'root', + webUrl: 'link-to-author', + avatarUrl: 'link-to-avatar', + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); + + it('renders a message about possibility to create a new discussion', () => { + expect(findNewDiscussionDisclaimer().exists()).toBe(true); + }); + + it('does not render unresolved discussions', () => { + expect(findUnresolvedDiscussions()).toHaveLength(0); + }); + }); + + describe('when showing resolved discussions for the first time', () => { + beforeEach(() => { + Cookies.set(cookieKey, false); + createComponent(); + }); + + it('renders a popover if we show resolved comments collapsible for the first time', () => { + expect(findPopover().exists()).toBe(true); + }); + + it('dismisses a popover on the outside click', () => { + wrapper.trigger('click'); + return wrapper.vm.$nextTick(() => { + expect(findPopover().exists()).toBe(false); + }); + }); + + it(`sets a ${cookieKey} cookie on clicking outside the popover`, () => { + jest.spyOn(Cookies, 'set'); + wrapper.trigger('click'); + expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 }); + }); + }); +}); 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 index 185bf4a48f7..27c0ba589e6 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap @@ -10,7 +10,7 @@ exports[`Design management upload button component renders inverted upload desig variant="success" > - Add designs + Upload designs <!----> </gl-deprecated-button-stub> @@ -34,7 +34,7 @@ exports[`Design management upload button component renders loading icon 1`] = ` variant="success" > - Add designs + Upload designs <gl-loading-icon-stub class="ml-1" @@ -63,7 +63,7 @@ exports[`Design management upload button component renders upload design button variant="success" > - Add designs + Upload designs <!----> </gl-deprecated-button-stub> diff --git a/spec/frontend/design_management/mock_data/design.js b/spec/frontend/design_management/mock_data/design.js index 34e3077f4a2..675198b9408 100644 --- a/spec/frontend/design_management/mock_data/design.js +++ b/spec/frontend/design_management/mock_data/design.js @@ -29,6 +29,7 @@ export default { { id: 'discussion-id', replyId: 'discussion-reply-id', + resolved: false, notes: { nodes: [ { @@ -44,6 +45,25 @@ export default { ], }, }, + { + id: 'discussion-resolved', + replyId: 'discussion-reply-resolved', + resolved: true, + notes: { + nodes: [ + { + id: 'note-resolved', + body: '123', + author: { + name: 'Administrator', + username: 'root', + webUrl: 'link-to-author', + avatarUrl: 'link-to-avatar', + }, + }, + ], + }, + }, ], }, diffRefs: { diff --git a/spec/frontend/design_management/mock_data/notes.js b/spec/frontend/design_management/mock_data/notes.js index db4624c8524..80cb3944786 100644 --- a/spec/frontend/design_management/mock_data/notes.js +++ b/spec/frontend/design_management/mock_data/notes.js @@ -1,32 +1,46 @@ export default [ { id: 'note-id-1', + index: 1, position: { height: 100, width: 100, x: 10, y: 15, }, + author: { + name: 'John', + webUrl: 'link-to-john-profile', + }, + createdAt: '2020-05-08T07:10:45Z', userPermissions: { adminNote: true, }, discussion: { id: 'discussion-id-1', }, + resolved: false, }, { id: 'note-id-2', + index: 2, position: { height: 50, width: 50, x: 25, y: 25, }, + author: { + name: 'Mary', + webUrl: 'link-to-mary-profile', + }, + createdAt: '2020-05-08T07:10:45Z', userPermissions: { adminNote: true, }, discussion: { id: 'discussion-id-2', }, + resolved: true, }, ]; diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index 76e481ee518..65c4811536e 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -16,7 +16,7 @@ exports[`Design management design index page renders design index 1`] = ` <!----> <design-presentation-stub - discussions="[object Object]" + discussions="[object Object],[object Object]" image="test.jpg" imagename="test.jpg" scale="1" @@ -33,58 +33,86 @@ exports[`Design management design index page renders design index 1`] = ` class="image-notes" > <h2 - class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0" + class="gl-font-weight-bold gl-mt-0" > - My precious issue - + My precious issue + </h2> <a - class="text-tertiary text-decoration-none mb-3 d-block" + class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" href="full-issue-url" > ull-issue-path </a> <participants-stub - class="mb-4" + class="gl-mb-4" numberoflessparticipants="7" participants="[object Object]" /> - <div - class="design-discussion-wrapper" + <!----> + + <design-discussion-stub + data-testid="unresolved-discussion" + designid="test" + discussion="[object Object]" + discussionwithopenform="" + markdownpreviewpath="//preview_markdown?target_type=Issue" + noteableid="design-id" + /> + + <gl-button-stub + category="tertiary" + class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4" + data-testid="resolved-comments" + icon="chevron-right" + id="resolved-comments" + size="medium" + variant="link" > - <div - class="badge badge-pill" - type="button" - > - 1 - </div> + Resolved Comments (1) + + </gl-button-stub> + + <gl-popover-stub + container="popovercontainer" + cssclasses="" + placement="top" + show="true" + target="resolved-comments" + title="Resolved Comments" + > + <p> + + Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below + + </p> - <div - class="design-discussion bordered-box position-relative" - data-qa-selector="design_discussion_content" + <a + href="#" + rel="noopener noreferrer" + target="_blank" > - <design-note-stub - class="" - markdownpreviewpath="//preview_markdown?target_type=Issue" - note="[object Object]" - /> - - <div - class="reply-wrapper" - > - <reply-placeholder-stub - buttontext="Reply..." - class="qa-discussion-reply" - /> - </div> - </div> - </div> + Learn more about resolving comments + </a> + </gl-popover-stub> + + <gl-collapse-stub + class="gl-mt-3" + > + <design-discussion-stub + data-testid="resolved-discussion" + designid="test" + discussion="[object Object]" + discussionwithopenform="" + markdownpreviewpath="//preview_markdown?target_type=Issue" + noteableid="design-id" + /> + </gl-collapse-stub> - <!----> </div> </div> `; @@ -152,33 +180,37 @@ exports[`Design management design index page with error GlAlert is rendered in c class="image-notes" > <h2 - class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0" + class="gl-font-weight-bold gl-mt-0" > - My precious issue - + My precious issue + </h2> <a - class="text-tertiary text-decoration-none mb-3 d-block" + class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" href="full-issue-url" > ull-issue-path </a> <participants-stub - class="mb-4" + class="gl-mb-4" numberoflessparticipants="7" participants="[object Object]" /> <h2 - class="new-discussion-disclaimer gl-font-base m-0" + class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4" + data-testid="new-discussion-disclaimer" > - Click the image where you'd like to start a new discussion - + Click the image where you'd like to start a new discussion + </h2> + + <!----> + </div> </div> `; diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 9e2f071a983..430cf8722fe 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -1,13 +1,12 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; import { GlAlert } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; import createFlash from '~/flash'; import DesignIndex from '~/design_management/pages/design/index.vue'; -import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; +import DesignSidebar from '~/design_management/components/design_sidebar.vue'; import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; -import Participants from '~/sidebar/components/participants/participants.vue'; import createImageDiffNoteMutation from '~/design_management/graphql/mutations/createImageDiffNote.mutation.graphql'; -import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql'; import design from '../../mock_data/design'; import mockResponseWithDesigns from '../../mock_data/designs'; import mockResponseNoDesigns from '../../mock_data/no_designs'; @@ -17,6 +16,9 @@ import { DESIGN_VERSION_NOT_EXIST_ERROR, } from '~/design_management/utils/error_messages'; import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; +import createRouter from '~/design_management/router'; +import * as utils from '~/design_management/utils/design_management_utils'; +import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants'; jest.mock('~/flash'); jest.mock('mousetrap', () => ({ @@ -24,8 +26,13 @@ jest.mock('mousetrap', () => ({ unbind: jest.fn(), })); +const localVue = createLocalVue(); +localVue.use(VueRouter); + describe('Design management design index page', () => { let wrapper; + let router; + const newComment = 'new comment'; const annotationCoordinates = { x: 10, @@ -53,23 +60,12 @@ describe('Design management design index page', () => { }, }; - const updateActiveDiscussionMutationVariables = { - mutation: updateActiveDiscussionMutation, - variables: { - id: design.discussions.nodes[0].notes.nodes[0].id, - source: 'discussion', - }, - }; - const mutate = jest.fn().mockResolvedValue(); - const routerPush = jest.fn(); - const findDiscussions = () => wrapper.findAll(DesignDiscussion); const findDiscussionForm = () => wrapper.find(DesignReplyForm); - const findParticipants = () => wrapper.find(Participants); - const findDiscussionsWrapper = () => wrapper.find('.image-notes'); + const findSidebar = () => wrapper.find(DesignSidebar); - function createComponent(loading = false, data = {}, { routeQuery = {} } = {}) { + function createComponent(loading = false, data = {}) { const $apollo = { queries: { design: { @@ -79,20 +75,14 @@ describe('Design management design index page', () => { mutate, }; - const $router = { - push: routerPush, - }; - - const $route = { - query: routeQuery, - }; + router = createRouter(); wrapper = shallowMount(DesignIndex, { propsData: { id: '1' }, - mocks: { $apollo, $router, $route }, + mocks: { $apollo }, stubs: { ApolloMutation, - DesignDiscussion, + DesignSidebar, }, data() { return { @@ -104,6 +94,8 @@ describe('Design management design index page', () => { ...data, }; }, + localVue, + router, }); } @@ -111,6 +103,23 @@ describe('Design management design index page', () => { wrapper.destroy(); }); + describe('when navigating', () => { + it('applies fullscreen layout', () => { + const mockEl = { + classList: { + add: jest.fn(), + remove: jest.fn(), + }, + }; + jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockEl); + createComponent(true); + + wrapper.vm.$router.push('/designs/test'); + expect(mockEl.classList.add).toHaveBeenCalledTimes(1); + expect(mockEl.classList.add).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + }); + }); + it('sets loading state', () => { createComponent(true); @@ -124,63 +133,13 @@ describe('Design management design index page', () => { expect(wrapper.find(GlAlert).exists()).toBe(false); }); - it('renders participants', () => { - createComponent(false, { design }); - - expect(findParticipants().exists()).toBe(true); - }); - - it('passes the correct amount of participants to the Participants component', () => { + it('passes correct props to sidebar component', () => { createComponent(false, { design }); - expect(findParticipants().props('participants')).toHaveLength(1); - }); - - describe('when has no discussions', () => { - beforeEach(() => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], - }, - }, - }); - }); - - it('does not render discussions', () => { - expect(findDiscussions().exists()).toBe(false); - }); - - it('renders a message about possibility to create a new discussion', () => { - expect(wrapper.find('.new-discussion-disclaimer').exists()).toBe(true); - }); - }); - - describe('when has discussions', () => { - beforeEach(() => { - createComponent(false, { design }); - }); - - it('renders correct amount of discussions', () => { - expect(findDiscussions()).toHaveLength(1); - }); - - it('sends a mutation to set an active discussion when clicking on a discussion', () => { - findDiscussions() - .at(0) - .trigger('click'); - - expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables); - }); - - it('sends a mutation to reset an active discussion when clicking outside of discussion', () => { - findDiscussionsWrapper().trigger('click'); - - expect(mutate).toHaveBeenCalledWith({ - ...updateActiveDiscussionMutationVariables, - variables: { id: undefined, source: 'discussion' }, - }); + expect(findSidebar().props()).toEqual({ + design, + markdownPreviewPath: '//preview_markdown?target_type=Issue', + resolvedDiscussionsExpanded: false, }); }); @@ -269,31 +228,35 @@ describe('Design management design index page', () => { describe('with no designs', () => { it('redirects to /designs', () => { createComponent(true); + router.push = jest.fn(); wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false }); return wrapper.vm.$nextTick().then(() => { expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR); - expect(routerPush).toHaveBeenCalledTimes(1); - expect(routerPush).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); + expect(router.push).toHaveBeenCalledTimes(1); + expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); }); }); }); describe('when no design exists for given version', () => { it('redirects to /designs', () => { - // attempt to query for a version of the design that doesn't exist - createComponent(true, {}, { routeQuery: { version: '999' } }); + createComponent(true); wrapper.setData({ allVersions: mockAllVersions, }); + // attempt to query for a version of the design that doesn't exist + router.push({ query: { version: '999' } }); + router.push = jest.fn(); + wrapper.vm.onDesignQueryResult({ data: mockResponseWithDesigns, loading: false }); return wrapper.vm.$nextTick().then(() => { expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledWith(DESIGN_VERSION_NOT_EXIST_ERROR); - expect(routerPush).toHaveBeenCalledTimes(1); - expect(routerPush).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); + expect(router.push).toHaveBeenCalledTimes(1); + expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); }); }); }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 2299b858da9..d4e9bae3e89 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -2,7 +2,6 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import { ApolloMutation } from 'vue-apollo'; import VueRouter from 'vue-router'; import { GlEmptyState } from '@gitlab/ui'; - import Index from '~/design_management/pages/index.vue'; import uploadDesignQuery from '~/design_management/graphql/mutations/uploadDesign.mutation.graphql'; import DesignDestroyer from '~/design_management/components/design_destroyer.vue'; @@ -14,20 +13,21 @@ import { EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, } from '~/design_management/utils/error_messages'; import createFlash from '~/flash'; +import createRouter from '~/design_management/router'; +import * as utils from '~/design_management/utils/design_management_utils'; +import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants'; + +jest.mock('~/flash.js'); +const mockPageEl = { + classList: { + remove: jest.fn(), + }, +}; +jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageEl); const localVue = createLocalVue(); +const router = createRouter(); localVue.use(VueRouter); -const router = new VueRouter({ - routes: [ - { - name: DESIGNS_ROUTE_NAME, - path: '/designs', - component: Index, - }, - ], -}); - -jest.mock('~/flash.js'); const mockDesigns = [ { @@ -530,4 +530,14 @@ describe('Design management index page', () => { expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled(); }); }); + + describe('when navigating', () => { + it('ensures fullscreen layout is not applied', () => { + createComponent(true); + + wrapper.vm.$router.push('/designs'); + expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1); + expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + }); + }); }); diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js index 0f4afa5e288..d6488d3837a 100644 --- a/spec/frontend/design_management/router_spec.js +++ b/spec/frontend/design_management/router_spec.js @@ -33,6 +33,7 @@ function factory(routeArg) { design: { loading: true }, permissions: { loading: true }, }, + mutate: jest.fn(), }, }, }); diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js index af631073df6..478ebadc8f6 100644 --- a/spec/frontend/design_management/utils/design_management_utils_spec.js +++ b/spec/frontend/design_management/utils/design_management_utils_spec.js @@ -53,10 +53,10 @@ describe('extractDiscussions', () => { it('discards the edges.node artifacts of GraphQL', () => { expect(extractDiscussions(discussions)).toEqual([ - { id: 1, notes: ['a'] }, - { id: 2, notes: ['b'] }, - { id: 3, notes: ['c'] }, - { id: 4, notes: ['d'] }, + { id: 1, notes: ['a'], index: 1 }, + { id: 2, notes: ['b'], index: 2 }, + { id: 3, notes: ['c'], index: 3 }, + { id: 4, notes: ['d'], index: 4 }, ]); }); }); |