summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
authorTim Zallmann <tzallmann@gitlab.com>2018-12-08 03:12:23 +0000
committerClement Ho <clemmakesapps@gmail.com>2018-12-08 03:12:23 +0000
commitddd4cc649f31b490a73e65808d828b2f51ca1917 (patch)
treefed840b9cd9f9ddc3a8ef96814508c0470d5b38a /app/assets/javascripts
parenta89b5269502a0250c7507f3bb1350a7ba602822b (diff)
downloadgitlab-ce-ddd4cc649f31b490a73e65808d828b2f51ca1917.tar.gz
Resolve "Extended user centric tooltips"
Diffstat (limited to 'app/assets/javascripts')
-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.js28
-rw-r--r--app/assets/javascripts/main.js2
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue5
-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.js107
-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.vue104
10 files changed, 280 insertions, 6 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index de003e70e61..e2740981a4b 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',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
@@ -254,6 +256,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));
@@ -276,7 +292,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 6e6efb04753..445d3267a3f 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('.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>