diff options
author | Tim Zallmann <tzallmann@gitlab.com> | 2018-11-29 10:00:58 +0100 |
---|---|---|
committer | Tim Zallmann <tzallmann@gitlab.com> | 2018-11-29 10:00:58 +0100 |
commit | 6b8b349dc5380afab057067418e314a6b241105c (patch) | |
tree | 70b359f15b039e1b12bcf8f091e9af156eb5ff11 | |
parent | aed88cd40d44ade30f5fe6dc8b572f3ace71f74f (diff) | |
download | gitlab-ce-50157-extended-user-centric-tooltips.tar.gz |
First version of user popover display50157-extended-user-centric-tooltips
-rw-r--r-- | app/assets/javascripts/api.js | 20 | ||||
-rw-r--r-- | app/assets/javascripts/behaviors/markdown/render_gfm.js | 2 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/users_cache.js | 34 | ||||
-rw-r--r-- | app/assets/javascripts/main.js | 2 | ||||
-rw-r--r-- | app/assets/javascripts/notes/components/note_header.vue | 9 | ||||
-rw-r--r-- | app/assets/javascripts/notes/components/notes_app.vue | 6 | ||||
-rw-r--r-- | app/assets/javascripts/user_popovers.js | 84 | ||||
-rw-r--r-- | app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue | 3 | ||||
-rw-r--r-- | app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue | 127 | ||||
-rw-r--r-- | app/helpers/issuables_helper.rb | 2 | ||||
-rw-r--r-- | app/helpers/projects_helper.rb | 2 | ||||
-rw-r--r-- | lib/banzai/filter/user_reference_filter.rb | 2 | ||||
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | yarn.lock | 8 |
14 files changed, 290 insertions, 13 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 3f7a1ef1bfc..e90618f2b39 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -19,7 +19,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', commitPipelinesPath: '/:project_id/commit/:sha/pipelines', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', @@ -243,6 +245,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)); @@ -265,7 +281,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..f47ce18bbe4 100644 --- a/app/assets/javascripts/lib/utils/users_cache.js +++ b/app/assets/javascripts/lib/utils/users_cache.js @@ -22,6 +22,40 @@ class UsersCache extends Cache { }); // missing catch is intentional, error handling depends on use case } + + retrieveById(userid) { + if (this.hasData(userid)) { + return Promise.resolve(this.get(userid)); + } + + return Api.user(userid).then(({ data }) => { + if (!data) { + throw new Error(`User "${userid}" could not be found!`); + } + + const user = data; + this.internalStorage[userid] = user; + + return user; + }); + } + + retrieveStatusById(userid) { + if (this.hasData(userid) && this.get(userid).status) { + return Promise.resolve(this.get(userid).status); + } + + return Api.userStatus(userid).then(({ data }) => { + if (!data) { + throw new Error(`User "${userid}" could not be found!`); + } + + const status = data; + this.internalStorage[userid].status = status; + + return status; + }); + } } 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_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 8b7450783c9..047cbc0c891 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" :href="author.path"> + <a + v-if="hasAuthor" + :href="author.path" + class="user-link" + :data-user-id="author.id" + :data-username="author.username" + :data-userdata="author" + > <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 79ece036e69..3ed6bc6ae17 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', @@ -106,7 +107,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('.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..18cabfea6fa --- /dev/null +++ b/app/assets/javascripts/user_popovers.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; + +import UsersCache from './lib/utils/users_cache'; +import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; + +let renderedPopover; + +const handleUserPopoverMouseOut = () => { + if (renderedPopover) { + renderedPopover.$destroy(); + renderedPopover = null; + } +}; + +const handleUserPopoverMouseOver = event => { + const { target } = event; + + // Copying for now so maybe we don't need a markdown update + if (target.dataset.user) { + target.dataset.userId = target.dataset.user; + target.dataset.originalTitle = ''; + target.setAttribute('title', ''); + } + + const { userId, userName, name, avatarUrl } = target.dataset; + if (userId || userName) { + renderedPopover = new Vue({ + ...UserPopover, + propsData: { + target, + userId, + userName, + name, + avatarUrl, + }, + }); + renderedPopover.$mount(); + + // Add listener to actually remove it again + target.addEventListener('mouseleave', handleUserPopoverMouseOut); + + UsersCache.retrieveById(userId) + .then(user => { + if (!user) { + return; + } + + Object.assign(renderedPopover, { + avatarUrl: user.avatar_url, + userName: user.username, + name: user.name, + location: user.location, + bio: user.bio, + organization: user.organization, + loaded: true, + }); + + UsersCache.retrieveStatusById(userId) + .then(status => { + if (!status) { + return; + } + + Object.assign(renderedPopover, { + status, + }); + }) + .catch(() => {}); + }) + .catch(() => {}); + } +}; + +export default elements => { + let userLinks = elements; + if (!elements) { + const userLinksSelector = document.querySelectorAll('.user-link'); + userLinks = [].slice.call(userLinksSelector); + } + + 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..4c5d7624f3e 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" :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..4cb764ea972 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -0,0 +1,127 @@ +<script> +import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; + +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, + }, + userName: { + type: String, + default: null, + }, + name: { + type: String, + default: null, + }, + avatarUrl: { + type: String, + default: '', + }, + bio: { + type: String, + default: null, + }, + organization: { + type: String, + default: null, + }, + location: { + type: String, + default: null, + }, + status: { + type: String, + default: null, + }, + loaded: { + type: Boolean, + default: false, + }, + }, + computed: { + jobLine() { + if (this.bio && this.organization) { + return `${this.bio} at ${this.organization}`; + } else if (this.bio) { + return this.bio; + } else if (this.organization) { + return this.bio; + } + return ''; + }, + statusHtml() { + if (this.status.emoji && this.status.message) { + return `${glEmojiTag(this.status.emoji)} ${this.status.message}`; + } else if (this.status.message) { + return this.status.message; + } + return ''; + }, + }, +}; +</script> + +<template> + <gl-popover :target="target" boundary="viewport" placement="top" title="" show> + <div class="d-flex bd-highlight"> + <div class="p-1 flex-shrink-1"> + <user-avatar-image :img-src="avatarUrl" :size="60" css-classes="mr-0" /> + </div> + <div class="p-1 w-100"> + <div> + <strong> + {{ name }} + <gl-skeleton-loading + v-if="name === null" + :lines="1" + class="animation-container-small mb-1" + /> + </strong> + </div> + <div class="text-secondary mb-2"> + <span v-if="userName">@{{ userName }}</span> + <gl-skeleton-loading + v-if="userName === null" + :lines="1" + class="animation-container-small mb-1" + /> + </div> + <div class="text-secondary"> + {{ jobLine }} + <gl-skeleton-loading + v-if="!loaded && organization === null" + :lines="1" + class="animation-container-small mb-1" + /> + </div> + <div class="text-secondary"> + {{ location }} + <gl-skeleton-loading + v-if="!loaded && location === null" + :lines="1" + class="animation-container-small mb-1" + /> + </div> + <div class="mt-2"> + <span v-if="status" v-html="statusHtml"></span> + <gl-skeleton-loading + v-if="!loaded && status === null" + :lines="1" + class="animation-container-small mb-1" + /> + </div> + </div> + </div> + </gl-popover> +</template> 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 0a7f930110a..56e222ffff4 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -65,7 +65,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 user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: { user_id: author.id, user_name: sanitize(author.username), name: sanitize(author.name) }).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/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/package.json b/package.json index 380f44946dc..ecfc2661d23 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@babel/plugin-syntax-import-meta": "^7.0.0", "@babel/preset-env": "^7.1.0", "@gitlab/svgs": "^1.38.0", - "@gitlab/ui": "^1.11.0", + "@gitlab/ui": "^1.13.0", "autosize": "^4.0.0", "axios": "^0.17.1", "babel-loader": "^8.0.4", diff --git a/yarn.lock b/yarn.lock index 62335ba5e59..5d1afe0b06e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -634,10 +634,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.38.0.tgz#e2f6e73379d60c7c63af4df8242a94c4671a1dfe" integrity sha512-Mzv6PxVbWEPvvMgXHaGxk8UE1Gard2gifca6loLgfLH7BtjXfESiZyJdQkkTSeBYp5MoqQa88Kw+vJYobwjsSw== -"@gitlab/ui@^1.11.0": - version "1.11.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.11.0.tgz#b771c2c3d627cf9efbe98c71ee5739624f2ff51f" - integrity sha512-hGMHM45kcv9725R6G+n/HxvF3KfVb9oBGRNf1+4n3xAGmtXJ2NlPdIXIsDaye3EeVF9PTOtjLuaqrcp6AGNqZg== +"@gitlab/ui@^1.13.0": + version "1.13.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.13.0.tgz#0ae0f28369a55ce43404e62c62d7ff51e9f018e5" + integrity sha512-FpvDB0YE2p1gP7h/JbW9tM/dCHbU4JaioA2xYbQi5GnEIuD9iq9xzqYNP0X6ZTYM02DmSrsdMhQoBmQ775c3Ew== dependencies: babel-standalone "^6.26.0" bootstrap-vue "^2.0.0-rc.11" |