diff options
Diffstat (limited to 'spec/frontend/sidebar')
16 files changed, 1308 insertions, 92 deletions
diff --git a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap index 1f93336e755..cf7832f3948 100644 --- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap +++ b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Confidential Issue Sidebar Block renders for isConfidential = false and isEditable = false 1`] = ` +exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = false 1`] = ` <div class="block issuable-sidebar-item confidentiality" > @@ -52,7 +52,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and </div> `; -exports[`Confidential Issue Sidebar Block renders for isConfidential = false and isEditable = true 1`] = ` +exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = true 1`] = ` <div class="block issuable-sidebar-item confidentiality" > @@ -84,9 +84,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and data-track-property="confidentiality" href="#" > - Edit - </a> </div> @@ -114,7 +112,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = false and </div> `; -exports[`Confidential Issue Sidebar Block renders for isConfidential = true and isEditable = false 1`] = ` +exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = false 1`] = ` <div class="block issuable-sidebar-item confidentiality" > @@ -166,7 +164,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = true and </div> `; -exports[`Confidential Issue Sidebar Block renders for isConfidential = true and isEditable = true 1`] = ` +exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = true 1`] = ` <div class="block issuable-sidebar-item confidentiality" > @@ -198,9 +196,7 @@ exports[`Confidential Issue Sidebar Block renders for isConfidential = true and data-track-property="confidentiality" href="#" > - Edit - </a> </div> diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js new file mode 100644 index 00000000000..1c62c52dc67 --- /dev/null +++ b/spec/frontend/sidebar/assignees_realtime_spec.js @@ -0,0 +1,102 @@ +import { shallowMount } from '@vue/test-utils'; +import ActionCable from '@rails/actioncable'; +import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import Mock from './mock_data'; +import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql'; + +jest.mock('@rails/actioncable', () => { + const mockConsumer = { + subscriptions: { create: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) }, + }; + return { + createConsumer: jest.fn().mockReturnValue(mockConsumer), + }; +}); + +describe('Assignees Realtime', () => { + let wrapper; + let mediator; + + const createComponent = () => { + wrapper = shallowMount(AssigneesRealtime, { + propsData: { + issuableIid: '1', + mediator, + projectPath: 'path/to/project', + }, + mocks: { + $apollo: { + query, + queries: { + project: { + refetch: jest.fn(), + }, + }, + }, + }, + }); + }; + + beforeEach(() => { + mediator = new SidebarMediator(Mock.mediator); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + SidebarMediator.singleton = null; + }); + + describe('when handleFetchResult is called from smart query', () => { + it('sets assignees to the store', () => { + const data = { + project: { + issue: { + assignees: { + nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }], + }, + }, + }, + }; + const expected = [{ id: 123, avatar_url: 'url', avatarUrl: 'url' }]; + createComponent(); + + wrapper.vm.handleFetchResult({ data }); + + expect(mediator.store.assignees).toEqual(expected); + }); + }); + + describe('when mounted', () => { + it('calls create subscription', () => { + const cable = ActionCable.createConsumer(); + + createComponent(); + + return wrapper.vm.$nextTick().then(() => { + expect(cable.subscriptions.create).toHaveBeenCalledTimes(1); + expect(cable.subscriptions.create).toHaveBeenCalledWith( + { + channel: 'IssuesChannel', + iid: wrapper.props('issuableIid'), + project_path: wrapper.props('projectPath'), + }, + { received: wrapper.vm.received }, + ); + }); + }); + }); + + describe('when subscription is recieved', () => { + it('refetches the GraphQL project query', () => { + createComponent(); + + wrapper.vm.received({ event: 'updated' }); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.$apollo.queries.project.refetch).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js new file mode 100644 index 00000000000..1f028f74423 --- /dev/null +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -0,0 +1,279 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; + +describe('Issuable Time Tracker', () => { + let initialData; + let vm; + + const initTimeTrackingComponent = ({ + timeEstimate, + timeSpent, + timeEstimateHumanReadable, + timeSpentHumanReadable, + limitToHours, + }) => { + setFixtures(` + <div> + <div id="mock-container"></div> + </div> + `); + + initialData = { + timeEstimate, + timeSpent, + humanTimeEstimate: timeEstimateHumanReadable, + humanTimeSpent: timeSpentHumanReadable, + limitToHours: Boolean(limitToHours), + rootPath: '/', + }; + + const TimeTrackingComponent = Vue.extend({ + ...TimeTracker, + components: { + ...TimeTracker.components, + transition: { + // disable animations + render(h) { + return h('div', this.$slots.default); + }, + }, + }, + }); + vm = mountComponent(TimeTrackingComponent, initialData, '#mock-container'); + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('Initialization', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 10000, // 2h 46m + timeSpent: 5000, // 1h 23m + timeEstimateHumanReadable: '2h 46m', + timeSpentHumanReadable: '1h 23m', + }); + }); + + it('should return something defined', () => { + expect(vm).toBeDefined(); + }); + + it('should correctly set timeEstimate', done => { + Vue.nextTick(() => { + expect(vm.timeEstimate).toBe(initialData.timeEstimate); + done(); + }); + }); + + it('should correctly set time_spent', done => { + Vue.nextTick(() => { + expect(vm.timeSpent).toBe(initialData.timeSpent); + done(); + }); + }); + }); + + describe('Content Display', () => { + describe('Panes', () => { + describe('Comparison pane', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 100000, // 1d 3h + timeSpent: 5000, // 1h 23m + timeEstimateHumanReadable: '1d 3h', + timeSpentHumanReadable: '1h 23m', + }); + }); + + it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', done => { + Vue.nextTick(() => { + expect(vm.showComparisonState).toBe(true); + const $comparisonPane = vm.$el.querySelector('.time-tracking-comparison-pane'); + + expect($comparisonPane).toBeVisible(); + done(); + }); + }); + + it('should show full times when the sidebar is collapsed', done => { + Vue.nextTick(() => { + const timeTrackingText = vm.$el.querySelector('.time-tracking-collapsed-summary span') + .textContent; + + expect(timeTrackingText.trim()).toBe('1h 23m / 1d 3h'); + done(); + }); + }); + + describe('Remaining meter', () => { + it('should display the remaining meter with the correct width', done => { + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.time-tracking-comparison-pane .progress[value="5"]'), + ).not.toBeNull(); + done(); + }); + }); + + it('should display the remaining meter with the correct background color when within estimate', done => { + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="primary"]'), + ).not.toBeNull(); + done(); + }); + }); + + it('should display the remaining meter with the correct background color when over estimate', done => { + vm.timeEstimate = 10000; // 2h 46m + vm.timeSpent = 20000000; // 231 days + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="danger"]'), + ).not.toBeNull(); + done(); + }); + }); + }); + }); + + describe('Comparison pane when limitToHours is true', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 100000, // 1d 3h + timeSpent: 5000, // 1h 23m + timeEstimateHumanReadable: '', + timeSpentHumanReadable: '', + limitToHours: true, + }); + }); + + it('should show the correct tooltip text', done => { + Vue.nextTick(() => { + expect(vm.showComparisonState).toBe(true); + const $title = vm.$el.querySelector('.time-tracking-content .compare-meter').dataset + .originalTitle; + + expect($title).toBe('Time remaining: 26h 23m'); + done(); + }); + }); + }); + + describe('Estimate only pane', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 10000, // 2h 46m + timeSpent: 0, + timeEstimateHumanReadable: '2h 46m', + timeSpentHumanReadable: '', + }); + }); + + it('should display the human readable version of time estimated', done => { + Vue.nextTick(() => { + const estimateText = vm.$el.querySelector('.time-tracking-estimate-only-pane') + .textContent; + const correctText = 'Estimated: 2h 46m'; + + expect(estimateText.trim()).toBe(correctText); + done(); + }); + }); + }); + + describe('Spent only pane', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 0, + timeSpent: 5000, // 1h 23m + timeEstimateHumanReadable: '2h 46m', + timeSpentHumanReadable: '1h 23m', + }); + }); + + it('should display the human readable version of time spent', done => { + Vue.nextTick(() => { + const spentText = vm.$el.querySelector('.time-tracking-spend-only-pane').textContent; + const correctText = 'Spent: 1h 23m'; + + expect(spentText).toBe(correctText); + done(); + }); + }); + }); + + describe('No time tracking pane', () => { + beforeEach(() => { + initTimeTrackingComponent({ + timeEstimate: 0, + timeSpent: 0, + timeEstimateHumanReadable: '', + timeSpentHumanReadable: '', + }); + }); + + it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', done => { + Vue.nextTick(() => { + const $noTrackingPane = vm.$el.querySelector('.time-tracking-no-tracking-pane'); + const noTrackingText = $noTrackingPane.textContent; + const correctText = 'No estimate or time spent'; + + expect(vm.showNoTimeTrackingState).toBe(true); + expect($noTrackingPane).toBeVisible(); + expect(noTrackingText.trim()).toBe(correctText); + done(); + }); + }); + }); + + describe('Help pane', () => { + const helpButton = () => vm.$el.querySelector('.help-button'); + const closeHelpButton = () => vm.$el.querySelector('.close-help-button'); + const helpPane = () => vm.$el.querySelector('.time-tracking-help-state'); + + beforeEach(() => { + initTimeTrackingComponent({ timeEstimate: 0, timeSpent: 0 }); + + return vm.$nextTick(); + }); + + it('should not show the "Help" pane by default', () => { + expect(vm.showHelpState).toBe(false); + expect(helpPane()).toBeNull(); + }); + + it('should show the "Help" pane when help button is clicked', () => { + helpButton().click(); + + return vm.$nextTick().then(() => { + expect(vm.showHelpState).toBe(true); + + // let animations run + jest.advanceTimersByTime(500); + + expect(helpPane()).toBeVisible(); + }); + }); + + it('should not show the "Help" pane when help button is clicked and then closed', done => { + helpButton().click(); + + Vue.nextTick() + .then(() => closeHelpButton().click()) + .then(() => Vue.nextTick()) + .then(() => { + expect(vm.showHelpState).toBe(false); + expect(helpPane()).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js new file mode 100644 index 00000000000..acdfb5139bf --- /dev/null +++ b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; + +describe('Edit Form Buttons', () => { + let wrapper; + const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]'); + + const createComponent = props => { + wrapper = shallowMount(EditFormButtons, { + propsData: { + updateConfidentialAttribute: () => {}, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when not confidential', () => { + it('renders Turn On in the ', () => { + createComponent({ + isConfidential: false, + }); + + expect(findConfidentialToggle().text()).toBe('Turn On'); + }); + }); + + describe('when confidential', () => { + it('renders on or off text based on confidentiality', () => { + createComponent({ + isConfidential: true, + }); + + expect(findConfidentialToggle().text()).toBe('Turn Off'); + }); + }); +}); diff --git a/spec/frontend/sidebar/confidential/edit_form_spec.js b/spec/frontend/sidebar/confidential/edit_form_spec.js new file mode 100644 index 00000000000..137019a1e1b --- /dev/null +++ b/spec/frontend/sidebar/confidential/edit_form_spec.js @@ -0,0 +1,45 @@ +import { shallowMount } from '@vue/test-utils'; +import EditForm from '~/sidebar/components/confidential/edit_form.vue'; + +describe('Edit Form Dropdown', () => { + let wrapper; + const toggleForm = () => {}; + const updateConfidentialAttribute = () => {}; + + const createComponent = props => { + wrapper = shallowMount(EditForm, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when not confidential', () => { + it('renders "You are going to turn off the confidentiality." in the ', () => { + createComponent({ + isConfidential: false, + toggleForm, + updateConfidentialAttribute, + }); + + expect(wrapper.find('p').text()).toContain('You are going to turn on the confidentiality.'); + }); + }); + + describe('when confidential', () => { + it('renders on or off text based on confidentiality', () => { + createComponent({ + isConfidential: true, + toggleForm, + updateConfidentialAttribute, + }); + + expect(wrapper.find('p').text()).toContain('You are going to turn off the confidentiality.'); + }); + }); +}); diff --git a/spec/frontend/sidebar/confidential_edit_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_buttons_spec.js deleted file mode 100644 index 32da9f83112..00000000000 --- a/spec/frontend/sidebar/confidential_edit_buttons_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; -import editFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; - -describe('Edit Form Buttons', () => { - let vm1; - let vm2; - - beforeEach(() => { - const Component = Vue.extend(editFormButtons); - const toggleForm = () => {}; - const updateConfidentialAttribute = () => {}; - - vm1 = new Component({ - propsData: { - isConfidential: true, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - - vm2 = new Component({ - propsData: { - isConfidential: false, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - }); - - it('renders on or off text based on confidentiality', () => { - expect(vm1.$el.innerHTML.includes('Turn Off')).toBe(true); - - expect(vm2.$el.innerHTML.includes('Turn On')).toBe(true); - }); -}); diff --git a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js deleted file mode 100644 index 369088cb258..00000000000 --- a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; -import editForm from '~/sidebar/components/confidential/edit_form.vue'; - -describe('Edit Form Dropdown', () => { - let vm1; - let vm2; - - beforeEach(() => { - const Component = Vue.extend(editForm); - const toggleForm = () => {}; - const updateConfidentialAttribute = () => {}; - - vm1 = new Component({ - propsData: { - isConfidential: true, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - - vm2 = new Component({ - propsData: { - isConfidential: false, - toggleForm, - updateConfidentialAttribute, - }, - }).$mount(); - }); - - it('renders on the appropriate warning text', () => { - expect(vm1.$el.innerHTML.includes('You are going to turn off the confidentiality.')).toBe(true); - - expect(vm2.$el.innerHTML.includes('You are going to turn on the confidentiality.')).toBe(true); - }); -}); diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js index 4853d9795b1..e7a64ec5ed9 100644 --- a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js +++ b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js @@ -5,6 +5,7 @@ import EditForm from '~/sidebar/components/confidential/edit_form.vue'; import SidebarService from '~/sidebar/services/sidebar_service'; import createFlash from '~/flash'; import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue'; +import createStore from '~/notes/stores'; jest.mock('~/flash'); jest.mock('~/sidebar/services/sidebar_service'); @@ -31,8 +32,10 @@ describe('Confidential Issue Sidebar Block', () => { }; const createComponent = propsData => { + const store = createStore(); const service = new SidebarService(); wrapper = shallowMount(ConfidentialIssueSidebar, { + store, propsData: { service, ...propsData, @@ -49,29 +52,31 @@ describe('Confidential Issue Sidebar Block', () => { }); it.each` - isConfidential | isEditable - ${false} | ${false} - ${false} | ${true} - ${true} | ${false} - ${true} | ${true} + confidential | isEditable + ${false} | ${false} + ${false} | ${true} + ${true} | ${false} + ${true} | ${true} `( - 'renders for isConfidential = $isConfidential and isEditable = $isEditable', - ({ isConfidential, isEditable }) => { + 'renders for confidential = $confidential and isEditable = $isEditable', + ({ confidential, isEditable }) => { createComponent({ - isConfidential, isEditable, }); + wrapper.vm.$store.state.noteableData.confidential = confidential; - expect(wrapper.element).toMatchSnapshot(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); }, ); describe('if editable', () => { beforeEach(() => { createComponent({ - isConfidential: true, isEditable: true, }); + wrapper.vm.$store.state.noteableData.confidential = true; }); it('displays the edit form when editable', () => { diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js new file mode 100644 index 00000000000..66f9237ce97 --- /dev/null +++ b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue'; + +describe('EditFormButtons', () => { + let wrapper; + + const mountComponent = propsData => shallowMount(EditFormButtons, { propsData }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays "Unlock" when locked', () => { + wrapper = mountComponent({ + isLocked: true, + updateLockedAttribute: () => {}, + }); + + expect(wrapper.text()).toContain('Unlock'); + }); + + it('displays "Lock" when unlocked', () => { + wrapper = mountComponent({ + isLocked: false, + updateLockedAttribute: () => {}, + }); + + expect(wrapper.text()).toContain('Lock'); + }); +}); diff --git a/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js b/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js new file mode 100644 index 00000000000..00997326d87 --- /dev/null +++ b/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js @@ -0,0 +1,99 @@ +import Vue from 'vue'; +import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; +import lockIssueSidebar from '~/sidebar/components/lock/lock_issue_sidebar.vue'; + +describe('LockIssueSidebar', () => { + let vm1; + let vm2; + + beforeEach(() => { + const Component = Vue.extend(lockIssueSidebar); + + const mediator = { + service: { + update: Promise.resolve(true), + }, + + store: { + isLockDialogOpen: false, + }, + }; + + vm1 = new Component({ + propsData: { + isLocked: true, + isEditable: true, + mediator, + issuableType: 'issue', + }, + }).$mount(); + + vm2 = new Component({ + propsData: { + isLocked: false, + isEditable: false, + mediator, + issuableType: 'merge_request', + }, + }).$mount(); + }); + + it('shows if locked and/or editable', () => { + expect(vm1.$el.innerHTML.includes('Edit')).toBe(true); + + expect(vm1.$el.innerHTML.includes('Locked')).toBe(true); + + expect(vm2.$el.innerHTML.includes('Unlocked')).toBe(true); + }); + + it('displays the edit form when editable', done => { + expect(vm1.isLockDialogOpen).toBe(false); + + vm1.$el.querySelector('.lock-edit').click(); + + expect(vm1.isLockDialogOpen).toBe(true); + + vm1.$nextTick(() => { + expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true); + + done(); + }); + }); + + it('tracks an event when "Edit" is clicked', () => { + const spy = mockTracking('_category_', vm1.$el, jest.spyOn); + triggerEvent('.lock-edit'); + + expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', { + label: 'right_sidebar', + property: 'lock_issue', + }); + }); + + it('displays the edit form when opened from collapsed state', done => { + expect(vm1.isLockDialogOpen).toBe(false); + + vm1.$el.querySelector('.sidebar-collapsed-icon').click(); + + expect(vm1.isLockDialogOpen).toBe(true); + + setImmediate(() => { + expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true); + + done(); + }); + }); + + it('does not display the edit form when opened from collapsed state if not editable', done => { + expect(vm2.isLockDialogOpen).toBe(false); + + vm2.$el.querySelector('.sidebar-collapsed-icon').click(); + + Vue.nextTick() + .then(() => { + expect(vm2.isLockDialogOpen).toBe(false); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js new file mode 100644 index 00000000000..ebe94582588 --- /dev/null +++ b/spec/frontend/sidebar/participants_spec.js @@ -0,0 +1,206 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Participants from '~/sidebar/components/participants/participants.vue'; + +const PARTICIPANT = { + id: 1, + state: 'active', + username: 'marcene', + name: 'Allie Will', + web_url: 'foo.com', + avatar_url: 'gravatar.com/avatar/xxx', +}; + +const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }]; + +describe('Participants', () => { + let wrapper; + + const getMoreParticipantsButton = () => wrapper.find('button'); + + const getCollapsedParticipantsCount = () => wrapper.find('[data-testid="collapsed-count"]'); + + const mountComponent = propsData => + shallowMount(Participants, { + propsData, + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('collapsed sidebar state', () => { + it('shows loading spinner when loading', () => { + wrapper = mountComponent({ + loading: true, + }); + + expect(wrapper.contains(GlLoadingIcon)).toBe(true); + }); + + it('does not show loading spinner not loading', () => { + wrapper = mountComponent({ + loading: false, + }); + + expect(wrapper.contains(GlLoadingIcon)).toBe(false); + }); + + it('shows participant count when given', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + }); + + expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`); + }); + + it('shows full participant count when there are hidden participants', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 1, + }); + + expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`); + }); + }); + + describe('expanded sidebar state', () => { + it('shows loading spinner when loading', () => { + wrapper = mountComponent({ + loading: true, + }); + + expect(wrapper.contains(GlLoadingIcon)).toBe(true); + }); + + it('when only showing visible participants, shows an avatar only for each participant under the limit', () => { + const numberOfLessParticipants = 2; + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + + wrapper.setData({ + isShowingMoreParticipants: false, + }); + + return Vue.nextTick().then(() => { + expect(wrapper.findAll('.participants-author')).toHaveLength(numberOfLessParticipants); + }); + }); + + it('when only showing all participants, each has an avatar', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + + wrapper.setData({ + isShowingMoreParticipants: true, + }); + + return Vue.nextTick().then(() => { + expect(wrapper.findAll('.participants-author')).toHaveLength(PARTICIPANT_LIST.length); + }); + }); + + it('does not have more participants link when they can all be shown', () => { + const numberOfLessParticipants = 100; + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + + expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants); + expect(getMoreParticipantsButton().exists()).toBe(false); + }); + + it('when too many participants, has more participants link to show more', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + + wrapper.setData({ + isShowingMoreParticipants: false, + }); + + return Vue.nextTick().then(() => { + expect(getMoreParticipantsButton().text()).toBe('+ 1 more'); + }); + }); + + it('when too many participants and already showing them, has more participants link to show less', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + + wrapper.setData({ + isShowingMoreParticipants: true, + }); + + return Vue.nextTick().then(() => { + expect(getMoreParticipantsButton().text()).toBe('- show less'); + }); + }); + + it('clicking more participants link emits event', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + + expect(wrapper.vm.isShowingMoreParticipants).toBe(false); + + getMoreParticipantsButton().trigger('click'); + + expect(wrapper.vm.isShowingMoreParticipants).toBe(true); + }); + + it('clicking on participants icon emits `toggleSidebar` event', () => { + wrapper = mountComponent({ + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + + const spy = jest.spyOn(wrapper.vm, '$emit'); + + wrapper.find('.sidebar-collapsed-icon').trigger('click'); + + return Vue.nextTick(() => { + expect(spy).toHaveBeenCalledWith('toggleSidebar'); + + spy.mockRestore(); + }); + }); + }); + + describe('when not showing participants label', () => { + beforeEach(() => { + wrapper = mountComponent({ + participants: PARTICIPANT_LIST, + showParticipantLabel: false, + }); + }); + + it('does not show sidebar collapsed icon', () => { + expect(wrapper.contains('.sidebar-collapsed-icon')).toBe(false); + }); + + it('does not show participants label title', () => { + expect(wrapper.contains('.title')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/sidebar/sidebar_assignees_spec.js b/spec/frontend/sidebar/sidebar_assignees_spec.js index c1876066a21..88e2d2c9514 100644 --- a/spec/frontend/sidebar/sidebar_assignees_spec.js +++ b/spec/frontend/sidebar/sidebar_assignees_spec.js @@ -3,6 +3,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import axios from 'axios'; import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.vue'; import Assigness from '~/sidebar/components/assignees/assignees.vue'; +import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarStore from '~/sidebar/stores/sidebar_store'; @@ -12,12 +13,19 @@ describe('sidebar assignees', () => { let wrapper; let mediator; let axiosMock; - - const createComponent = () => { + const createComponent = (realTimeIssueSidebar = false, props) => { wrapper = shallowMount(SidebarAssignees, { propsData: { + issuableIid: '1', mediator, field: '', + projectPath: 'projectPath', + ...props, + }, + provide: { + glFeatures: { + realTimeIssueSidebar, + }, }, // Attaching to document is required because this component emits something from the parent element :/ attachToDocument: true, @@ -30,8 +38,6 @@ describe('sidebar assignees', () => { jest.spyOn(mediator, 'saveAssignees'); jest.spyOn(mediator, 'assignYourself'); - - createComponent(); }); afterEach(() => { @@ -45,6 +51,8 @@ describe('sidebar assignees', () => { }); it('calls the mediator when saves the assignees', () => { + createComponent(); + expect(mediator.saveAssignees).not.toHaveBeenCalled(); wrapper.vm.saveAssignees(); @@ -53,6 +61,8 @@ describe('sidebar assignees', () => { }); it('calls the mediator when "assignSelf" method is called', () => { + createComponent(); + expect(mediator.assignYourself).not.toHaveBeenCalled(); expect(mediator.store.assignees.length).toBe(0); @@ -63,6 +73,8 @@ describe('sidebar assignees', () => { }); it('hides assignees until fetched', () => { + createComponent(); + expect(wrapper.find(Assigness).exists()).toBe(false); wrapper.vm.store.isFetching.assignees = false; @@ -71,4 +83,30 @@ describe('sidebar assignees', () => { expect(wrapper.find(Assigness).exists()).toBe(true); }); }); + + describe('when realTimeIssueSidebar is turned on', () => { + describe('when issuableType is issue', () => { + it('finds AssigneesRealtime componeont', () => { + createComponent(true); + + expect(wrapper.find(AssigneesRealtime).exists()).toBe(true); + }); + }); + + describe('when issuableType is MR', () => { + it('does not find AssigneesRealtime componeont', () => { + createComponent(true, { issuableType: 'MR' }); + + expect(wrapper.find(AssigneesRealtime).exists()).toBe(false); + }); + }); + }); + + describe('when realTimeIssueSidebar is turned off', () => { + it('does not find AssigneesRealtime', () => { + createComponent(false, { issuableType: 'issue' }); + + expect(wrapper.find(AssigneesRealtime).exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js new file mode 100644 index 00000000000..0892d452966 --- /dev/null +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -0,0 +1,135 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import * as urlUtility from '~/lib/utils/url_utility'; +import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('Sidebar mediator', () => { + const { mediator: mediatorMockData } = Mock; + let mock; + let mediator; + + beforeEach(() => { + mock = new MockAdapter(axios); + mediator = new SidebarMediator(mediatorMockData); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + mock.restore(); + }); + + it('assigns yourself ', () => { + mediator.assignYourself(); + + expect(mediator.store.currentUser).toEqual(mediatorMockData.currentUser); + expect(mediator.store.assignees[0]).toEqual(mediatorMockData.currentUser); + }); + + it('saves assignees', () => { + mock.onPut(mediatorMockData.endpoint).reply(200, {}); + + return mediator.saveAssignees('issue[assignee_ids]').then(resp => { + expect(resp.status).toEqual(200); + }); + }); + + it('fetches the data', () => { + const mockData = Mock.responseMap.GET[mediatorMockData.endpoint]; + mock.onGet(mediatorMockData.endpoint).reply(200, mockData); + + const mockGraphQlData = Mock.graphQlResponseData; + const graphQlSpy = jest.spyOn(gqClient, 'query').mockReturnValue({ + data: mockGraphQlData, + }); + const spy = jest.spyOn(mediator, 'processFetchedData').mockReturnValue(Promise.resolve()); + + return mediator.fetch().then(() => { + expect(spy).toHaveBeenCalledWith(mockData, mockGraphQlData); + + spy.mockRestore(); + graphQlSpy.mockRestore(); + }); + }); + + it('processes fetched data', () => { + const mockData = Mock.responseMap.GET[mediatorMockData.endpoint]; + mediator.processFetchedData(mockData); + + expect(mediator.store.assignees).toEqual(mockData.assignees); + expect(mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate); + expect(mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent); + expect(mediator.store.participants).toEqual(mockData.participants); + expect(mediator.store.subscribed).toEqual(mockData.subscribed); + expect(mediator.store.timeEstimate).toEqual(mockData.time_estimate); + expect(mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent); + }); + + it('sets moveToProjectId', () => { + const projectId = 7; + const spy = jest.spyOn(mediator.store, 'setMoveToProjectId').mockReturnValue(Promise.resolve()); + + mediator.setMoveToProjectId(projectId); + + expect(spy).toHaveBeenCalledWith(projectId); + + spy.mockRestore(); + }); + + it('fetches autocomplete projects', () => { + const searchTerm = 'foo'; + mock.onGet(mediatorMockData.projectsAutocompleteEndpoint).reply(200, {}); + const getterSpy = jest + .spyOn(mediator.service, 'getProjectsAutocomplete') + .mockReturnValue(Promise.resolve({ data: {} })); + const setterSpy = jest + .spyOn(mediator.store, 'setAutocompleteProjects') + .mockReturnValue(Promise.resolve()); + + return mediator.fetchAutocompleteProjects(searchTerm).then(() => { + expect(getterSpy).toHaveBeenCalledWith(searchTerm); + expect(setterSpy).toHaveBeenCalled(); + + getterSpy.mockRestore(); + setterSpy.mockRestore(); + }); + }); + + it('moves issue', () => { + const mockData = Mock.responseMap.POST[mediatorMockData.moveIssueEndpoint]; + const moveToProjectId = 7; + mock.onPost(mediatorMockData.moveIssueEndpoint).reply(200, mockData); + mediator.store.setMoveToProjectId(moveToProjectId); + const moveIssueSpy = jest + .spyOn(mediator.service, 'moveIssue') + .mockReturnValue(Promise.resolve({ data: { web_url: mockData.web_url } })); + const urlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({}); + + return mediator.moveIssue().then(() => { + expect(moveIssueSpy).toHaveBeenCalledWith(moveToProjectId); + expect(urlSpy).toHaveBeenCalledWith(mockData.web_url); + + moveIssueSpy.mockRestore(); + urlSpy.mockRestore(); + }); + }); + + it('toggle subscription', () => { + mediator.store.setSubscribedState(false); + mock.onPost(mediatorMockData.toggleSubscriptionEndpoint).reply(200, {}); + const spy = jest + .spyOn(mediator.service, 'toggleSubscription') + .mockReturnValue(Promise.resolve()); + + return mediator.toggleSubscription().then(() => { + expect(spy).toHaveBeenCalled(); + expect(mediator.store.subscribed).toEqual(true); + + spy.mockRestore(); + }); + }); +}); diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/sidebar_move_issue_spec.js new file mode 100644 index 00000000000..db0d3e06272 --- /dev/null +++ b/spec/frontend/sidebar/sidebar_move_issue_spec.js @@ -0,0 +1,167 @@ +import $ from 'jquery'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue'; +import Mock from './mock_data'; + +describe('SidebarMoveIssue', () => { + let mock; + const test = {}; + + beforeEach(() => { + mock = new MockAdapter(axios); + const mockData = Mock.responseMap.GET['/autocomplete/projects?project_id=15']; + mock.onGet('/autocomplete/projects?project_id=15').reply(200, mockData); + test.mediator = new SidebarMediator(Mock.mediator); + test.$content = $(` + <div class="dropdown"> + <div class="js-toggle"></div> + <div class="dropdown-menu"> + <div class="dropdown-content"></div> + </div> + <div class="js-confirm-button"></div> + </div> + `); + test.$toggleButton = test.$content.find('.js-toggle'); + test.$confirmButton = test.$content.find('.js-confirm-button'); + + test.sidebarMoveIssue = new SidebarMoveIssue( + test.mediator, + test.$toggleButton, + test.$confirmButton, + ); + test.sidebarMoveIssue.init(); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + + test.sidebarMoveIssue.destroy(); + mock.restore(); + }); + + describe('init', () => { + it('should initialize the dropdown and listeners', () => { + jest.spyOn(test.sidebarMoveIssue, 'initDropdown').mockImplementation(() => {}); + jest.spyOn(test.sidebarMoveIssue, 'addEventListeners').mockImplementation(() => {}); + + test.sidebarMoveIssue.init(); + + expect(test.sidebarMoveIssue.initDropdown).toHaveBeenCalled(); + expect(test.sidebarMoveIssue.addEventListeners).toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + it('should remove the listeners', () => { + jest.spyOn(test.sidebarMoveIssue, 'removeEventListeners').mockImplementation(() => {}); + + test.sidebarMoveIssue.destroy(); + + expect(test.sidebarMoveIssue.removeEventListeners).toHaveBeenCalled(); + }); + }); + + describe('initDropdown', () => { + it('should initialize the gl_dropdown', () => { + jest.spyOn($.fn, 'glDropdown').mockImplementation(() => {}); + + test.sidebarMoveIssue.initDropdown(); + + expect($.fn.glDropdown).toHaveBeenCalled(); + }); + + it('escapes html from project name', done => { + test.$toggleButton.dropdown('toggle'); + + setImmediate(() => { + expect(test.$content.find('.js-move-issue-dropdown-item')[1].innerHTML.trim()).toEqual( + '<img src=x onerror=alert(document.domain)> foo / bar', + ); + done(); + }); + }); + }); + + describe('onConfirmClicked', () => { + it('should move the issue with valid project ID', () => { + jest.spyOn(test.mediator, 'moveIssue').mockReturnValue(Promise.resolve()); + test.mediator.setMoveToProjectId(7); + + test.sidebarMoveIssue.onConfirmClicked(); + + expect(test.mediator.moveIssue).toHaveBeenCalled(); + expect(test.$confirmButton.prop('disabled')).toBeTruthy(); + expect(test.$confirmButton.hasClass('is-loading')).toBe(true); + }); + + it('should remove loading state from confirm button on failure', done => { + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + jest.spyOn(test.mediator, 'moveIssue').mockReturnValue(Promise.reject()); + test.mediator.setMoveToProjectId(7); + + test.sidebarMoveIssue.onConfirmClicked(); + + expect(test.mediator.moveIssue).toHaveBeenCalled(); + // Wait for the move issue request to fail + setImmediate(() => { + expect(window.Flash).toHaveBeenCalled(); + expect(test.$confirmButton.prop('disabled')).toBeFalsy(); + expect(test.$confirmButton.hasClass('is-loading')).toBe(false); + done(); + }); + }); + + it('should not move the issue with id=0', () => { + jest.spyOn(test.mediator, 'moveIssue').mockImplementation(() => {}); + test.mediator.setMoveToProjectId(0); + + test.sidebarMoveIssue.onConfirmClicked(); + + expect(test.mediator.moveIssue).not.toHaveBeenCalled(); + }); + }); + + it('should set moveToProjectId on dropdown item "No project" click', done => { + jest.spyOn(test.mediator, 'setMoveToProjectId').mockImplementation(() => {}); + + // Open the dropdown + test.$toggleButton.dropdown('toggle'); + + // Wait for the autocomplete request to finish + setImmediate(() => { + test.$content + .find('.js-move-issue-dropdown-item') + .eq(0) + .trigger('click'); + + expect(test.mediator.setMoveToProjectId).toHaveBeenCalledWith(0); + expect(test.$confirmButton.prop('disabled')).toBeTruthy(); + done(); + }); + }); + + it('should set moveToProjectId on dropdown item click', done => { + jest.spyOn(test.mediator, 'setMoveToProjectId').mockImplementation(() => {}); + + // Open the dropdown + test.$toggleButton.dropdown('toggle'); + + // Wait for the autocomplete request to finish + setImmediate(() => { + test.$content + .find('.js-move-issue-dropdown-item') + .eq(1) + .trigger('click'); + + expect(test.mediator.setMoveToProjectId).toHaveBeenCalledWith(20); + expect(test.$confirmButton.attr('disabled')).toBe(undefined); + done(); + }); + }); +}); diff --git a/spec/frontend/sidebar/sidebar_subscriptions_spec.js b/spec/frontend/sidebar/sidebar_subscriptions_spec.js new file mode 100644 index 00000000000..18aaeabe3dd --- /dev/null +++ b/spec/frontend/sidebar/sidebar_subscriptions_spec.js @@ -0,0 +1,36 @@ +import { shallowMount } from '@vue/test-utils'; +import SidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('Sidebar Subscriptions', () => { + let wrapper; + let mediator; + + beforeEach(() => { + mediator = new SidebarMediator(Mock.mediator); + wrapper = shallowMount(SidebarSubscriptions, { + propsData: { + mediator, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('calls the mediator toggleSubscription on event', () => { + const spy = jest.spyOn(mediator, 'toggleSubscription').mockReturnValue(Promise.resolve()); + + wrapper.vm.onToggleSubscription(); + + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js new file mode 100644 index 00000000000..cce35666985 --- /dev/null +++ b/spec/frontend/sidebar/subscriptions_spec.js @@ -0,0 +1,106 @@ +import { shallowMount } from '@vue/test-utils'; +import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; +import eventHub from '~/sidebar/event_hub'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; + +describe('Subscriptions', () => { + let wrapper; + + const findToggleButton = () => wrapper.find(ToggleButton); + + const mountComponent = propsData => + shallowMount(Subscriptions, { + propsData, + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('shows loading spinner when loading', () => { + wrapper = mountComponent({ + loading: true, + subscribed: undefined, + }); + + expect(findToggleButton().attributes('isloading')).toBe('true'); + }); + + it('is toggled "off" when currently not subscribed', () => { + wrapper = mountComponent({ + subscribed: false, + }); + + expect(findToggleButton().attributes('value')).toBeFalsy(); + }); + + it('is toggled "on" when currently subscribed', () => { + wrapper = mountComponent({ + subscribed: true, + }); + + expect(findToggleButton().attributes('value')).toBe('true'); + }); + + it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => { + const id = 42; + wrapper = mountComponent({ subscribed: true, id }); + const eventHubSpy = jest.spyOn(eventHub, '$emit'); + const wrapperEmitSpy = jest.spyOn(wrapper.vm, '$emit'); + + wrapper.vm.toggleSubscription(); + + expect(eventHubSpy).toHaveBeenCalledWith('toggleSubscription', id); + expect(wrapperEmitSpy).toHaveBeenCalledWith('toggleSubscription', id); + eventHubSpy.mockRestore(); + wrapperEmitSpy.mockRestore(); + }); + + it('tracks the event when toggled', () => { + wrapper = mountComponent({ subscribed: true }); + + const wrapperTrackSpy = jest.spyOn(wrapper.vm, 'track'); + + wrapper.vm.toggleSubscription(); + + expect(wrapperTrackSpy).toHaveBeenCalledWith('toggle_button', { + property: 'notifications', + value: 0, + }); + wrapperTrackSpy.mockRestore(); + }); + + it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => { + wrapper = mountComponent({ subscribed: true }); + const spy = jest.spyOn(wrapper.vm, '$emit'); + + wrapper.vm.onClickCollapsedIcon(); + + expect(spy).toHaveBeenCalledWith('toggleSidebar'); + spy.mockRestore(); + }); + + describe('given project emails are disabled', () => { + const subscribeDisabledDescription = 'Notifications have been disabled'; + + beforeEach(() => { + wrapper = mountComponent({ + subscribed: false, + projectEmailsDisabled: true, + subscribeDisabledDescription, + }); + }); + + it('sets the correct display text', () => { + expect(wrapper.find('.issuable-header-text').text()).toContain(subscribeDisabledDescription); + expect(wrapper.find({ ref: 'tooltip' }).attributes('data-original-title')).toBe( + subscribeDisabledDescription, + ); + }); + + it('does not render the toggle button', () => { + expect(wrapper.contains('.js-issuable-subscribe-button')).toBe(false); + }); + }); +}); |