diff options
28 files changed, 678 insertions, 15 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index e5a628ab653..7607c4b3b79 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -21,7 +21,9 @@ const Api = { projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', projectTemplatesPath: '/api/:version/projects/:id/templates/:type', usersPath: '/api/:version/users.json', - userStatusPath: '/api/:version/user/status', + userPath: '/api/:version/users/:id', + userStatusPath: '/api/:version/users/:id/status', + userPostStatusPath: '/api/:version/user/status', commitPath: '/api/:version/projects/:id/repository/commits', applySuggestionPath: '/api/:version/suggestions/:id/apply', commitPipelinesPath: '/:project_id/commit/:sha/pipelines', @@ -261,6 +263,20 @@ const Api = { }); }, + user(id, options) { + const url = Api.buildUrl(this.userPath).replace(':id', encodeURIComponent(id)); + return axios.get(url, { + params: options, + }); + }, + + userStatus(id, options) { + const url = Api.buildUrl(this.userStatusPath).replace(':id', encodeURIComponent(id)); + return axios.get(url, { + params: options, + }); + }, + branches(id, query = '', options = {}) { const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); @@ -283,7 +299,7 @@ const Api = { }, postUserStatus({ emoji, message }) { - const url = Api.buildUrl(this.userStatusPath); + const url = Api.buildUrl(this.userPostStatusPath); return axios.put(url, { emoji, diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index a2d4331b6d1..fc9286d15e6 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight'; import renderMath from './render_math'; import renderMermaid from './render_mermaid'; import highlightCurrentUser from './highlight_current_user'; +import initUserPopovers from '../../user_popovers'; // Render GitLab flavoured Markdown // @@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() { renderMath(this.find('.js-render-math')); renderMermaid(this.find('.js-render-mermaid')); highlightCurrentUser(this.find('.gfm-project_member').get()); + initUserPopovers(this.find('.gfm-project_member').get()); return this; }; diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js index c0d45e017b4..9f980fd4899 100644 --- a/app/assets/javascripts/lib/utils/users_cache.js +++ b/app/assets/javascripts/lib/utils/users_cache.js @@ -22,6 +22,34 @@ class UsersCache extends Cache { }); // missing catch is intentional, error handling depends on use case } + + retrieveById(userId) { + if (this.hasData(userId) && this.get(userId).username) { + return Promise.resolve(this.get(userId)); + } + + return Api.user(userId).then(({ data }) => { + this.internalStorage[userId] = data; + return data; + }); + // missing catch is intentional, error handling depends on use case + } + + retrieveStatusById(userId) { + if (this.hasData(userId) && this.get(userId).status) { + return Promise.resolve(this.get(userId).status); + } + + return Api.userStatus(userId).then(({ data }) => { + if (!this.hasData(userId)) { + this.internalStorage[userId] = {}; + } + this.internalStorage[userId].status = data; + + return data; + }); + // missing catch is intentional, error handling depends on use case + } } export default new UsersCache(); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index a88b575ad99..c866e8d180a 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent'; import initPerformanceBar from './performance_bar'; import initSearchAutocomplete from './search_autocomplete'; import GlFieldErrors from './gl_field_errors'; +import initUserPopovers from './user_popovers'; // expose jQuery as global (TODO: remove these) window.jQuery = jQuery; @@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => { initTodoToggle(); initLogoAnimation(); initUsagePingConsent(); + initUserPopovers(); if (document.querySelector('.search')) initSearchAutocomplete(); if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index 3d3dbbd7fe1..15ce49d7c31 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -39,7 +39,10 @@ export default { <div :class="className"> {{ actionText }} <template v-if="editedBy"> - by <a :href="editedBy.path" class="js-vue-author author-link"> {{ editedBy.name }} </a> + by + <a :href="editedBy.path" :data-user-id="editedBy.id" class="js-user-link author-link"> + {{ editedBy.name }} + </a> </template> {{ actionDetailText }} <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index e1a58e7cb26..7b39901024d 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -73,7 +73,14 @@ export default { {{ __('Toggle discussion') }} </button> </div> - <a v-if="hasAuthor" v-once :href="author.path"> + <a + v-if="hasAuthor" + v-once + :href="author.path" + class="js-user-link" + :data-user-id="author.id" + :data-username="author.username" + > <span class="note-header-author-name">{{ author.name }}</span> <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> <span class="note-headline-light"> @{{ author.username }} </span> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 506cc82db4b..f3fcfdfda05 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -12,6 +12,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note. import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; +import initUserPopovers from '../../user_popovers'; export default { name: 'NotesApp', @@ -111,7 +112,10 @@ export default { } }, updated() { - this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'))); + this.$nextTick(() => { + highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')); + initUserPopovers(this.$el.querySelectorAll('.js-user-link')); + }); }, methods: { ...mapActions([ diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js new file mode 100644 index 00000000000..948f4d5e631 --- /dev/null +++ b/app/assets/javascripts/user_popovers.js @@ -0,0 +1,107 @@ +import Vue from 'vue'; + +import UsersCache from './lib/utils/users_cache'; +import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; + +let renderedPopover; +let renderFn; + +const handleUserPopoverMouseOut = event => { + const { target } = event; + target.removeEventListener('mouseleave', handleUserPopoverMouseOut); + + if (renderFn) { + clearTimeout(renderFn); + } + if (renderedPopover) { + renderedPopover.$destroy(); + renderedPopover = null; + } +}; + +/** + * Adds a UserPopover component to the body, hands over as much data as the target element has in data attributes. + * loads based on data-user-id more data about a user from the API and sets it on the popover + */ +const handleUserPopoverMouseOver = event => { + const { target } = event; + // Add listener to actually remove it again + target.addEventListener('mouseleave', handleUserPopoverMouseOut); + + renderFn = setTimeout(() => { + // Helps us to use current markdown setup without maybe breaking or duplicating for now + if (target.dataset.user) { + target.dataset.userId = target.dataset.user; + // Removing titles so its not showing tooltips also + target.dataset.originalTitle = ''; + target.setAttribute('title', ''); + } + + const { userId, username, name, avatarUrl } = target.dataset; + const user = { + userId, + username, + name, + avatarUrl, + location: null, + bio: null, + organization: null, + status: null, + loaded: false, + }; + if (userId || username) { + const UserPopoverComponent = Vue.extend(UserPopover); + renderedPopover = new UserPopoverComponent({ + propsData: { + target, + user, + }, + }); + + renderedPopover.$mount(); + + UsersCache.retrieveById(userId) + .then(userData => { + if (!userData) { + return; + } + + Object.assign(user, { + avatarUrl: userData.avatar_url, + username: userData.username, + name: userData.name, + location: userData.location, + bio: userData.bio, + organization: userData.organization, + loaded: true, + }); + + UsersCache.retrieveStatusById(userId) + .then(status => { + if (!status) { + return; + } + + Object.assign(user, { + status, + }); + }) + .catch(() => { + throw new Error(`User status for "${userId}" could not be retrieved!`); + }); + }) + .catch(() => { + renderedPopover.$destroy(); + renderedPopover = null; + }); + } + }, 200); // 200ms delay so not every mouseover triggers Popover + API Call +}; + +export default elements => { + const userLinks = elements || [...document.querySelectorAll('.js-user-link')]; + + userLinks.forEach(el => { + el.addEventListener('mouseenter', handleUserPopoverMouseOver); + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index 01b8b94f9e3..e833a8e0483 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -67,7 +67,7 @@ export default { // In both cases we should render the defaultAvatarUrl sanitizedSource() { let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`; + if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`; return baseSrc; }, resultantSrcAttribute() { @@ -97,6 +97,7 @@ export default { class="avatar" /> <gl-tooltip + v-if="tooltipText || $slots.default" :target="() => $refs.userAvatarImage" :placement="tooltipPlacement" boundary="window" diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue new file mode 100644 index 00000000000..7fbadcc0111 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -0,0 +1,104 @@ +<script> +import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; +import { glEmojiTag } from '../../../emoji'; + +export default { + name: 'UserPopover', + components: { + GlPopover, + GlSkeletonLoading, + UserAvatarImage, + }, + props: { + target: { + type: HTMLAnchorElement, + required: true, + }, + user: { + type: Object, + required: true, + default: null, + }, + loaded: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + jobLine() { + if (this.user.bio && this.user.organization) { + return sprintf(__('%{bio} at %{organization}'), { + bio: this.user.bio, + organization: this.user.organization, + }); + } else if (this.user.bio) { + return this.user.bio; + } else if (this.user.organization) { + return this.user.organization; + } + return null; + }, + statusHtml() { + if (this.user.status.emoji && this.user.status.message) { + return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`; + } else if (this.user.status.message) { + return this.user.status.message; + } + return ''; + }, + nameIsLoading() { + return !this.user.name; + }, + jobInfoIsLoading() { + return !this.user.loaded && this.user.organization === null; + }, + locationIsLoading() { + return !this.user.loaded && this.user.location === null; + }, + }, +}; +</script> + +<template> + <gl-popover :target="target" boundary="viewport" placement="top" show> + <div class="user-popover d-flex"> + <div class="p-1 flex-shrink-1"> + <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" /> + </div> + <div class="p-1 w-100"> + <h5 class="m-0"> + {{ user.name }} + <gl-skeleton-loading + v-if="nameIsLoading" + :lines="1" + class="animation-container-small mb-1" + /> + </h5> + <div class="text-secondary mb-2"> + <span v-if="user.username">@{{ user.username }}</span> + <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> + </div> + <div class="text-secondary"> + {{ jobLine }} + <gl-skeleton-loading + v-if="jobInfoIsLoading" + :lines="1" + class="animation-container-small mb-1" + /> + </div> + <div class="text-secondary"> + {{ user.location }} + <gl-skeleton-loading + v-if="locationIsLoading" + :lines="1" + class="animation-container-small mb-1" + /> + </div> + <div v-if="user.status" class="mt-2"><span v-html="statusHtml"></span></div> + </div> + </div> + </gl-popover> +</template> diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index bd1cca69c03..985fac11c87 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -35,6 +35,11 @@ @import "pages/**/*"; /* + * Component specific styles, will be moved to gitlab-ui + */ +@import "components/**/*"; + +/* * Code highlight */ @import "highlight/dark"; diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss new file mode 100644 index 00000000000..2f4d30fe923 --- /dev/null +++ b/app/assets/stylesheets/components/popover.scss @@ -0,0 +1,9 @@ +.popover { + min-width: 300px; + + .popover-body .user-popover { + padding: $gl-padding-8; + font-size: $gl-font-size-small; + line-height: $gl-line-height; + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 8de6294eab4..38fb0026e54 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -172,6 +172,7 @@ $theme-light-red-700: #a62e21; $black: #000; $black-transparent: rgba(0, 0, 0, 0.3); +$shadow-color: rgba($black, 0.1); $almost-black: #242424; $border-white-light: darken($white-light, $darken-border-factor); diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index fab1b361f14..b12305f635d 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -21,3 +21,8 @@ $danger: $red-500; $zindex-modal-backdrop: 1040; $nav-divider-margin-y: ($grid-size / 2); $dropdown-divider-bg: $theme-gray-200; +$popover-max-width: 300px; +$popover-border-width: 1px; +$popover-border-color: $border-color; +$popover-box-shadow: 0 $border-radius-small $border-radius-default 0 $shadow-color; +$popover-arrow-outer-color: $shadow-color; diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index dfa86f52e40..da991458ea7 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -179,7 +179,7 @@ module IssuablesHelper output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe output << content_tag(:strong) do - author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline", tooltip: true) + author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline") author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none") if status = user_status(issuable.author) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 7ce6b04df7e..35e66f59dfe 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -50,6 +50,12 @@ module ProjectsHelper default_opts = { avatar: true, name: true, title: ":name" } opts = default_opts.merge(opts) + data_attrs = { + user_id: author.id, + username: author.username, + name: author.name + } + return "(deleted)" unless author author_html = [] @@ -65,7 +71,7 @@ module ProjectsHelper author_html = author_html.join.html_safe if opts[:name] - link_to(author_html, user_path(author), class: "author-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe + link_to(author_html, user_path(author), class: "author-link js-user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: data_attrs).html_safe else title = opts[:title].sub(":name", sanitize(author.name)) link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body' }).html_safe diff --git a/changelogs/unreleased/50157-extended-user-centric-tooltips.yml b/changelogs/unreleased/50157-extended-user-centric-tooltips.yml new file mode 100644 index 00000000000..3b55a867b87 --- /dev/null +++ b/changelogs/unreleased/50157-extended-user-centric-tooltips.yml @@ -0,0 +1,5 @@ +--- +title: Extended user centric tooltips on issue and MR page +merge_request: 23231 +author: +type: added diff --git a/lib/banzai/filter/user_reference_filter.rb b/lib/banzai/filter/user_reference_filter.rb index 11960047e5b..8cda67867a8 100644 --- a/lib/banzai/filter/user_reference_filter.rb +++ b/lib/banzai/filter/user_reference_filter.rb @@ -106,7 +106,7 @@ module Banzai end def link_class - reference_class(:project_member) + reference_class(:project_member, tooltip: false) end def link_to_all(link_content: nil) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2534c67c335..27f1a7a7165 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -97,6 +97,9 @@ msgstr[1] "" msgid "%{actionText} & %{openOrClose} %{noteable}" msgstr "" +msgid "%{bio} at %{organization}" +msgstr "" + msgid "%{commit_author_link} authored %{commit_timeago}" msgstr "" 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(` + <a href="/root" data-user-id="1" class="js-user-link" data-username="root" data-original-title="" title=""> + Root + </a> + `); + + 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 <span>', 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(` + <a href="/root" data-user-id="1" class="js-user-link" title="testuser"> + Root + </a> + `); + }); + + 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('<gl-emoji data-name="basketball_player"'); + }); + }); +}); diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb index 334d29a5368..1e8a44b4549 100644 --- a/spec/lib/banzai/filter/user_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb @@ -120,7 +120,7 @@ describe Banzai::Filter::UserReferenceFilter do it 'includes default classes' do doc = reference_filter("Hey #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member has-tooltip' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member' end context 'when a project is not specified' do |