summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTim Zallmann <tzallmann@gitlab.com>2018-11-29 10:00:58 +0100
committerTim Zallmann <tzallmann@gitlab.com>2018-11-29 10:00:58 +0100
commit6b8b349dc5380afab057067418e314a6b241105c (patch)
tree70b359f15b039e1b12bcf8f091e9af156eb5ff11
parentaed88cd40d44ade30f5fe6dc8b572f3ace71f74f (diff)
downloadgitlab-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.js20
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js2
-rw-r--r--app/assets/javascripts/lib/utils/users_cache.js34
-rw-r--r--app/assets/javascripts/main.js2
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue9
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue6
-rw-r--r--app/assets/javascripts/user_popovers.js84
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue127
-rw-r--r--app/helpers/issuables_helper.rb2
-rw-r--r--app/helpers/projects_helper.rb2
-rw-r--r--lib/banzai/filter/user_reference_filter.rb2
-rw-r--r--package.json2
-rw-r--r--yarn.lock8
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"