From ddd4cc649f31b490a73e65808d828b2f51ca1917 Mon Sep 17 00:00:00 2001 From: Tim Zallmann Date: Sat, 8 Dec 2018 03:12:23 +0000 Subject: Resolve "Extended user centric tooltips" --- spec/javascripts/api_spec.js | 34 ++++++ spec/javascripts/lib/utils/users_cache_spec.js | 110 ++++++++++++++++- .../notes/components/note_edited_text_spec.js | 2 +- .../notes/components/note_header_spec.js | 3 + spec/javascripts/user_popovers_spec.js | 66 ++++++++++ .../user_avatar/user_avatar_image_spec.js | 13 ++ .../user_avatar/user_avatar_link_spec.js | 4 +- .../components/user_popover/user_popover_spec.js | 133 +++++++++++++++++++++ 8 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 spec/javascripts/user_popovers_spec.js create mode 100644 spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js (limited to 'spec/javascripts') diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 46f72214831..9d55c615450 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -333,6 +333,40 @@ describe('Api', () => { }); }); + describe('user', () => { + it('fetches single user', done => { + const userId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`; + mock.onGet(expectedUrl).reply(200, { + name: 'testuser', + }); + + Api.user(userId) + .then(({ data }) => { + expect(data.name).toBe('testuser'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('user status', () => { + it('fetches single user status', done => { + const userId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`; + mock.onGet(expectedUrl).reply(200, { + message: 'testmessage', + }); + + Api.userStatus(userId) + .then(({ data }) => { + expect(data.message).toBe('testmessage'); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('commitPipelines', () => { it('fetches pipelines for a given commit', done => { const projectId = 'example/foobar'; diff --git a/spec/javascripts/lib/utils/users_cache_spec.js b/spec/javascripts/lib/utils/users_cache_spec.js index 6adc19bdd51..acb5e024acd 100644 --- a/spec/javascripts/lib/utils/users_cache_spec.js +++ b/spec/javascripts/lib/utils/users_cache_spec.js @@ -3,7 +3,9 @@ import UsersCache from '~/lib/utils/users_cache'; describe('UsersCache', () => { const dummyUsername = 'win'; - const dummyUser = 'has a farm'; + const dummyUserId = 123; + const dummyUser = { name: 'has a farm', username: 'farmer' }; + const dummyUserStatus = 'my status'; beforeEach(() => { UsersCache.internalStorage = {}; @@ -135,4 +137,110 @@ describe('UsersCache', () => { .catch(done.fail); }); }); + + describe('retrieveById', () => { + let apiSpy; + + beforeEach(() => { + spyOn(Api, 'user').and.callFake(id => apiSpy(id)); + }); + + it('stores and returns data from API call if cache is empty', done => { + apiSpy = id => { + expect(id).toBe(dummyUserId); + return Promise.resolve({ + data: dummyUser, + }); + }; + + UsersCache.retrieveById(dummyUserId) + .then(user => { + expect(user).toBe(dummyUser); + expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser); + }) + .then(done) + .catch(done.fail); + }); + + it('returns undefined if Ajax call fails and cache is empty', done => { + const dummyError = new Error('server exploded'); + apiSpy = id => { + expect(id).toBe(dummyUserId); + return Promise.reject(dummyError); + }; + + UsersCache.retrieveById(dummyUserId) + .then(user => fail(`Received unexpected user: ${JSON.stringify(user)}`)) + .catch(error => { + expect(error).toBe(dummyError); + }) + .then(done) + .catch(done.fail); + }); + + it('makes no Ajax call if matching data exists', done => { + UsersCache.internalStorage[dummyUserId] = dummyUser; + apiSpy = () => fail(new Error('expected no Ajax call!')); + + UsersCache.retrieveById(dummyUserId) + .then(user => { + expect(user).toBe(dummyUser); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('retrieveStatusById', () => { + let apiSpy; + + beforeEach(() => { + spyOn(Api, 'userStatus').and.callFake(id => apiSpy(id)); + }); + + it('stores and returns data from API call if cache is empty', done => { + apiSpy = id => { + expect(id).toBe(dummyUserId); + return Promise.resolve({ + data: dummyUserStatus, + }); + }; + + UsersCache.retrieveStatusById(dummyUserId) + .then(userStatus => { + expect(userStatus).toBe(dummyUserStatus); + expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus); + }) + .then(done) + .catch(done.fail); + }); + + it('returns undefined if Ajax call fails and cache is empty', done => { + const dummyError = new Error('server exploded'); + apiSpy = id => { + expect(id).toBe(dummyUserId); + return Promise.reject(dummyError); + }; + + UsersCache.retrieveStatusById(dummyUserId) + .then(userStatus => fail(`Received unexpected user: ${JSON.stringify(userStatus)}`)) + .catch(error => { + expect(error).toBe(dummyError); + }) + .then(done) + .catch(done.fail); + }); + + it('makes no Ajax call if matching data exists', done => { + UsersCache.internalStorage[dummyUserId] = { status: dummyUserStatus }; + apiSpy = () => fail(new Error('expected no Ajax call!')); + + UsersCache.retrieveStatusById(dummyUserId) + .then(userStatus => { + expect(userStatus).toBe(dummyUserStatus); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/notes/components/note_edited_text_spec.js b/spec/javascripts/notes/components/note_edited_text_spec.js index e0b991c32ec..e4c8d954d50 100644 --- a/spec/javascripts/notes/components/note_edited_text_spec.js +++ b/spec/javascripts/notes/components/note_edited_text_spec.js @@ -39,7 +39,7 @@ describe('note_edited_text', () => { }); it('should render provided user information', () => { - const authorLink = vm.$el.querySelector('.js-vue-author'); + const authorLink = vm.$el.querySelector('.js-user-link'); expect(authorLink.getAttribute('href')).toEqual(props.editedBy.path); expect(authorLink.textContent.trim()).toEqual(props.editedBy.name); diff --git a/spec/javascripts/notes/components/note_header_spec.js b/spec/javascripts/notes/components/note_header_spec.js index 379780f43a0..6d1a7ef370f 100644 --- a/spec/javascripts/notes/components/note_header_spec.js +++ b/spec/javascripts/notes/components/note_header_spec.js @@ -42,6 +42,9 @@ describe('note_header component', () => { it('should render user information', () => { expect(vm.$el.querySelector('.note-header-author-name').textContent.trim()).toEqual('Root'); expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual('/root'); + expect(vm.$el.querySelector('.note-header-info a').dataset.userId).toEqual('1'); + expect(vm.$el.querySelector('.note-header-info a').dataset.username).toEqual('root'); + expect(vm.$el.querySelector('.note-header-info a').classList).toContain('js-user-link'); }); it('should render timestamp link', () => { diff --git a/spec/javascripts/user_popovers_spec.js b/spec/javascripts/user_popovers_spec.js new file mode 100644 index 00000000000..6cf8dd81b36 --- /dev/null +++ b/spec/javascripts/user_popovers_spec.js @@ -0,0 +1,66 @@ +import initUserPopovers from '~/user_popovers'; +import UsersCache from '~/lib/utils/users_cache'; + +describe('User Popovers', () => { + const selector = '.js-user-link'; + + const dummyUser = { name: 'root' }; + const dummyUserStatus = { message: 'active' }; + + const triggerEvent = (eventName, el) => { + const event = document.createEvent('MouseEvents'); + event.initMouseEvent(eventName, true, true, window); + + el.dispatchEvent(event); + }; + + beforeEach(() => { + setFixtures(` + + Root + + `); + + const usersCacheSpy = () => Promise.resolve(dummyUser); + spyOn(UsersCache, 'retrieveById').and.callFake(userId => usersCacheSpy(userId)); + + const userStatusCacheSpy = () => Promise.resolve(dummyUserStatus); + spyOn(UsersCache, 'retrieveStatusById').and.callFake(userId => userStatusCacheSpy(userId)); + + initUserPopovers(document.querySelectorAll('.js-user-link')); + }); + + it('Should Show+Hide Popover on mouseenter and mouseleave', done => { + triggerEvent('mouseenter', document.querySelector(selector)); + + setTimeout(() => { + const shownPopover = document.querySelector('.popover'); + + expect(shownPopover).not.toBeNull(); + + expect(shownPopover.innerHTML).toContain(dummyUser.name); + expect(UsersCache.retrieveById).toHaveBeenCalledWith('1'); + + triggerEvent('mouseleave', document.querySelector(selector)); + + setTimeout(() => { + // After Mouse leave it should be hidden now + expect(document.querySelector('.popover')).toBeNull(); + done(); + }); + }, 210); // We need to wait until the 200ms mouseover delay is over, only then the popover will be visible + }); + + it('Should Not show a popover on short mouse over', done => { + triggerEvent('mouseenter', document.querySelector(selector)); + + setTimeout(() => { + expect(document.querySelector('.popover')).toBeNull(); + expect(UsersCache.retrieveById).not.toHaveBeenCalledWith('1'); + + triggerEvent('mouseleave', document.querySelector(selector)); + + done(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js index 5c4aa7cf844..c5045afc5b0 100644 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import { placeholderImage } from '~/lazy_loader'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper'; +import defaultAvatarUrl from '~/../images/no_avatar.png'; const DEFAULT_PROPS = { size: 99, @@ -76,6 +77,18 @@ describe('User Avatar Image Component', function() { }); }); + describe('Initialization without src', function() { + beforeEach(function() { + vm = mountComponent(UserAvatarImage); + }); + + it('should have default avatar image', function() { + const imageElement = vm.$el.querySelector('img'); + + expect(imageElement.getAttribute('src')).toBe(defaultAvatarUrl); + }); + }); + describe('dynamic tooltip content', () => { const props = DEFAULT_PROPS; const slots = { diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js index 0151ad23ba2..f2472fd377c 100644 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -74,9 +74,7 @@ describe('User Avatar Link Component', function() { describe('username', function() { it('should not render avatar image tooltip', function() { - expect( - this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip').innerText.trim(), - ).toEqual(''); + expect(this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip')).toBeNull(); }); it('should render username prop in ', function() { diff --git a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js new file mode 100644 index 00000000000..1578b0f81f9 --- /dev/null +++ b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js @@ -0,0 +1,133 @@ +import Vue from 'vue'; +import userPopover from '~/vue_shared/components/user_popover/user_popover.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +const DEFAULT_PROPS = { + loaded: true, + user: { + username: 'root', + name: 'Administrator', + location: 'Vienna', + bio: null, + organization: null, + status: null, + }, +}; + +const UserPopover = Vue.extend(userPopover); + +describe('User Popover Component', () => { + let vm; + + beforeEach(() => { + setFixtures(` + + Root + + `); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('Empty', () => { + beforeEach(() => { + vm = mountComponent(UserPopover, { + target: document.querySelector('.js-user-link'), + user: { + name: null, + username: null, + location: null, + bio: null, + organization: null, + status: null, + }, + }); + }); + + it('should return skeleton loaders', () => { + expect(vm.$el.querySelectorAll('.animation-container').length).toBe(4); + }); + }); + + describe('basic data', () => { + it('should show basic fields', () => { + vm = mountComponent(UserPopover, { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }); + + expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.name); + expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.username); + expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.location); + }); + }); + + describe('job data', () => { + it('should show only bio if no organization is available', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.bio = 'Engineer'; + + vm = mountComponent(UserPopover, { + ...testProps, + target: document.querySelector('.js-user-link'), + }); + + expect(vm.$el.textContent).toContain('Engineer'); + }); + + it('should show only organization if no bio is available', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.organization = 'GitLab'; + + vm = mountComponent(UserPopover, { + ...testProps, + target: document.querySelector('.js-user-link'), + }); + + expect(vm.$el.textContent).toContain('GitLab'); + }); + + it('should have full job line when we have bio and organization', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.bio = 'Engineer'; + testProps.user.organization = 'GitLab'; + + vm = mountComponent(UserPopover, { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }); + + expect(vm.$el.textContent).toContain('Engineer at GitLab'); + }); + }); + + describe('status data', () => { + it('should show only message', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.status = { message: 'Hello World' }; + + vm = mountComponent(UserPopover, { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }); + + expect(vm.$el.textContent).toContain('Hello World'); + }); + + it('should show message and emoji', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.status = { emoji: 'basketball_player', message: 'Hello World' }; + + vm = mountComponent(UserPopover, { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + status: { emoji: 'basketball_player', message: 'Hello World' }, + }); + + expect(vm.$el.textContent).toContain('Hello World'); + expect(vm.$el.innerHTML).toContain('