diff options
author | Clement Ho <clemmakesapps@gmail.com> | 2018-12-08 06:12:23 +0300 |
---|---|---|
committer | Clement Ho <clemmakesapps@gmail.com> | 2018-12-08 06:12:23 +0300 |
commit | f3299596c7d9d7b042859253606bab9972ed4a13 (patch) | |
tree | fed840b9cd9f9ddc3a8ef96814508c0470d5b38a /app | |
parent | a89b5269502a0250c7507f3bb1350a7ba602822b (diff) | |
parent | ddd4cc649f31b490a73e65808d828b2f51ca1917 (diff) |
Merge branch '50157-extended-user-centric-tooltips' into 'master'
Resolve "Extended user centric tooltips"
Closes #50157
See merge request gitlab-org/gitlab-ce!23231
Diffstat (limited to 'app')
-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 | 28 | ||||
-rw-r--r-- | app/assets/javascripts/main.js | 2 | ||||
-rw-r--r-- | app/assets/javascripts/notes/components/note_edited_text.vue | 5 | ||||
-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 | 107 | ||||
-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 | 104 | ||||
-rw-r--r-- | app/assets/stylesheets/application.scss | 5 | ||||
-rw-r--r-- | app/assets/stylesheets/components/popover.scss | 9 | ||||
-rw-r--r-- | app/assets/stylesheets/framework/variables.scss | 1 | ||||
-rw-r--r-- | app/assets/stylesheets/framework/variables_overrides.scss | 5 | ||||
-rw-r--r-- | app/helpers/issuables_helper.rb | 2 | ||||
-rw-r--r-- | app/helpers/projects_helper.rb | 8 |
16 files changed, 308 insertions, 8 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> 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 134b3a4521b..2dba2c61631 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 |