Commit 2fc61b12 authored by Clement Ho's avatar Clement Ho

Merge branch 'ee-50157-extended-user-centric-tooltips' into 'master'

EE Compat Version for Resolve "Extended User Centric Tooltips"

See merge request gitlab-org/gitlab-ee!8759
parents e26d96d3 57b141a1
image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.9-git-2.18-chrome-69.0-node-10.x-yarn-1.12-postgresql-9.6-graphicsmagick-1.3.29"
image: 'dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.9-git-2.18-chrome-69.0-node-10.x-yarn-1.12-postgresql-9.6-graphicsmagick-1.3.29'
.dedicated-runner: &dedicated-runner
retry: 1
......@@ -6,7 +6,7 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.9-git
- gitlab-org
.default-cache: &default-cache
key: "debian-stretch-ruby-2.5.3-node-10.x"
key: 'debian-stretch-ruby-2.5.3-node-10.x'
paths:
- vendor/ruby
- .yarn-cache/
......@@ -23,20 +23,20 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.9-git
policy: pull
variables:
MYSQL_ALLOW_EMPTY_PASSWORD: "1"
RAILS_ENV: "test"
NODE_ENV: "test"
SIMPLECOV: "true"
GIT_DEPTH: "20"
GIT_SUBMODULE_STRATEGY: "none"
GET_SOURCES_ATTEMPTS: "3"
MYSQL_ALLOW_EMPTY_PASSWORD: '1'
RAILS_ENV: 'test'
NODE_ENV: 'test'
SIMPLECOV: 'true'
GIT_DEPTH: '20'
GIT_SUBMODULE_STRATEGY: 'none'
GET_SOURCES_ATTEMPTS: '3'
KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/${CI_PROJECT_NAME}/rspec_report-master.json
FLAKY_RSPEC_SUITE_REPORT_PATH: rspec_flaky/report-suite.json
BUILD_ASSETS_IMAGE: "false"
BUILD_ASSETS_IMAGE: 'false'
## EE specific variables ##
# This hack is needed to make ES not that memory hungry
ES_JAVA_OPTS: "-Xms256m -Xmx256m"
ELASTIC_URL: "http://elastic:changeme@docker.elastic.co-elasticsearch-elasticsearch:9200"
ES_JAVA_OPTS: '-Xms256m -Xmx256m'
ELASTIC_URL: 'http://elastic:changeme@docker.elastic.co-elasticsearch-elasticsearch:9200'
EE_KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/${CI_PROJECT_NAME}/rspec_report-master-ee.json
before_script:
......@@ -63,7 +63,7 @@ stages:
.tests-metadata-state: &tests-metadata-state
<<: *dedicated-runner
variables:
TESTS_METADATA_S3_BUCKET: "gitlab-ce-cache"
TESTS_METADATA_S3_BUCKET: 'gitlab-ce-cache'
before_script:
- source scripts/utils.sh
artifacts:
......@@ -117,8 +117,8 @@ stages:
- $CI_COMMIT_REF_NAME =~ /norails4/
- $RAILS5_DISABLED
variables:
BUNDLE_GEMFILE: "Gemfile.rails4"
RAILS5: "false"
BUNDLE_GEMFILE: 'Gemfile.rails4'
RAILS5: 'false'
# Skip all jobs except the ones that begin with 'docs/'.
# Used for commits including ONLY documentation changes.
......@@ -149,7 +149,7 @@ stages:
.dedicated-no-docs-no-db-pull-cache-job: &dedicated-no-docs-no-db-pull-cache-job
<<: *dedicated-no-docs-pull-cache-job
variables:
SETUP_DB: "false"
SETUP_DB: 'false'
.dedicated-no-docs-and-no-qa-pull-cache-job: &dedicated-no-docs-and-no-qa-pull-cache-job
<<: *dedicated-no-docs-pull-cache-job
......@@ -292,12 +292,12 @@ stages:
<<: *dedicated-no-docs-and-no-qa-pull-cache-job
<<: *use-pg
variables:
SETUP_DB: "false"
SETUP_DB: 'false'
script:
# Manually clone gitlab-test and only seed this project in
# db/fixtures/development/04_project.rb thanks to SIZE=1 below
- git clone https://gitlab.com/gitlab-org/gitlab-test.git
/home/git/repositories/gitlab-org/gitlab-test.git
/home/git/repositories/gitlab-org/gitlab-test.git
- scripts/gitaly-test-spawn
- force=yes SIZE=1 FIXTURE_PATH="db/fixtures/development" bundle exec rake gitlab:setup
artifacts:
......@@ -315,7 +315,7 @@ stages:
.migration-paths: &migration-paths
<<: *dedicated-no-docs-and-no-qa-pull-cache-job
variables:
SETUP_DB: "false"
SETUP_DB: 'false'
script:
- git fetch https://gitlab.com/gitlab-org/gitlab-ee.git v9.3.0-ee
- git checkout -f FETCH_HEAD
......@@ -336,7 +336,7 @@ stages:
.migration-paths-upgrade-ce-to-ee: &migration-paths-upgrade-ce-to-ee
<<: *dedicated-no-docs-and-no-qa-pull-cache-job
variables:
SETUP_DB: "false"
SETUP_DB: 'false'
script:
- ruby -r./scripts/ee_specific_check/ee_specific_check -e'EESpecificCheck.fetch_remote_ce_branch'
- git checkout -f FETCH_HEAD
......@@ -434,7 +434,7 @@ cloud-native-image:
stage: post-test
allow_failure: true
variables:
GIT_DEPTH: "1"
GIT_DEPTH: '1'
cache: {}
script:
- gem install gitlab --no-document
......@@ -515,8 +515,8 @@ flaky-examples-check:
services: []
before_script: []
variables:
SETUP_DB: "false"
USE_BUNDLE_INSTALL: "false"
SETUP_DB: 'false'
USE_BUNDLE_INSTALL: 'false'
NEW_FLAKY_SPECS_REPORT: rspec_flaky/report-new.json
stage: post-test
allow_failure: true
......@@ -607,11 +607,11 @@ setup-test-env:
- gitlab-org
- docker
variables: &review-docker-variables
GIT_DEPTH: "1"
GIT_DEPTH: '1'
DOCKER_DRIVER: overlay2
DOCKER_HOST: tcp://docker:2375
LATEST_QA_IMAGE: "gitlab/${CI_PROJECT_NAME}-qa:nightly"
QA_IMAGE: "${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab/${CI_PROJECT_NAME}-qa:${CI_COMMIT_REF_SLUG}"
LATEST_QA_IMAGE: 'gitlab/${CI_PROJECT_NAME}-qa:nightly'
QA_IMAGE: '${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab/${CI_PROJECT_NAME}-qa:${CI_COMMIT_REF_SLUG}'
build-qa-image:
<<: *review-docker
......@@ -691,7 +691,7 @@ static-analysis:
script:
- scripts/static-analysis
cache:
key: "debian-stretch-ruby-2.5.3-node-10.x-and-rubocop"
key: 'debian-stretch-ruby-2.5.3-node-10.x-and-rubocop'
paths:
- vendor/ruby
- .yarn-cache/
......@@ -703,10 +703,10 @@ static-analysis:
docs lint:
<<: *dedicated-runner
<<: *except-qa
image: "registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-docs-lint"
image: 'registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-docs-lint'
stage: test
variables:
SETUP_DB: "false"
SETUP_DB: 'false'
cache: {}
dependencies: []
before_script: []
......@@ -718,7 +718,8 @@ docs lint:
# Build HTML from Markdown
- bundle exec nanoc
# Check the internal links
- bundle exec nanoc check internal_links
# Disabled until https://gitlab.com/gitlab-com/gitlab-docs/issues/305 is resolved
# - bundle exec nanoc check internal_links
downtime_check:
<<: *rake-exec
......@@ -747,7 +748,7 @@ ee_compat_check:
- branches@gitlab/gitlab-ee
retry: 0
artifacts:
name: "${CI_JOB_NAME}_${CI_COMIT_REF_NAME}_${CI_COMMIT_SHA}"
name: '${CI_JOB_NAME}_${CI_COMIT_REF_NAME}_${CI_COMMIT_SHA}'
when: always
expire_in: 10d
paths:
......@@ -820,11 +821,11 @@ gitlab:assets:compile:
services:
- docker:stable-dind
variables:
NODE_ENV: "production"
RAILS_ENV: "production"
SETUP_DB: "false"
SKIP_STORAGE_VALIDATION: "true"
WEBPACK_REPORT: "true"
NODE_ENV: 'production'
RAILS_ENV: 'production'
SETUP_DB: 'false'
SKIP_STORAGE_VALIDATION: 'true'
WEBPACK_REPORT: 'true'
# we override the max_old_space_size to prevent OOM errors
NODE_OPTIONS: --max_old_space_size=3584
DOCKER_DRIVER: overlay2
......@@ -881,8 +882,8 @@ jest:
<<: *dedicated-no-docs-and-no-qa-pull-cache-job
<<: *use-pg
dependencies:
- compile-assets
- setup-test-env
- compile-assets
- setup-test-env
script:
- scripts/gitaly-test-spawn
- date
......@@ -894,8 +895,8 @@ jest:
expire_in: 31d
when: always
paths:
- coverage-frontend/
- junit_jest.xml
- coverage-frontend/
- junit_jest.xml
reports:
junit: junit_jest.xml
cache:
......@@ -914,7 +915,7 @@ code_quality:
services:
- docker:stable-dind
variables:
SETUP_DB: "false"
SETUP_DB: 'false'
DOCKER_DRIVER: overlay2
cache: {}
dependencies: []
......@@ -922,10 +923,10 @@ code_quality:
# Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Security Products
- export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
- docker run
--env SOURCE_CODE="$PWD"
--volume "$PWD":/code
--volume /var/run/docker.sock:/var/run/docker.sock
"registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code
--env SOURCE_CODE="$PWD"
--volume "$PWD":/code
--volume /var/run/docker.sock:/var/run/docker.sock
"registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code
artifacts:
reports:
codequality: gl-code-quality-report.json
......@@ -947,10 +948,10 @@ sast:
script:
- export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
- docker run
--env SAST_CONFIDENCE_LEVEL="${SAST_CONFIDENCE_LEVEL:-3}"
--volume "$PWD:/code"
--volume /var/run/docker.sock:/var/run/docker.sock
"registry.gitlab.com/gitlab-org/security-products/sast:$SP_VERSION" /app/bin/run /code
--env SAST_CONFIDENCE_LEVEL="${SAST_CONFIDENCE_LEVEL:-3}"
--volume "$PWD:/code"
--volume /var/run/docker.sock:/var/run/docker.sock
"registry.gitlab.com/gitlab-org/security-products/sast:$SP_VERSION" /app/bin/run /code
artifacts:
reports:
sast: gl-sast-report.json
......@@ -970,10 +971,10 @@ dependency_scanning:
script:
- export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
- docker run
--env DEP_SCAN_DISABLE_REMOTE_CHECKS="${DEP_SCAN_DISABLE_REMOTE_CHECKS:-false}"
--volume "$PWD:/code"
--volume /var/run/docker.sock:/var/run/docker.sock
"registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$SP_VERSION" /code
--env DEP_SCAN_DISABLE_REMOTE_CHECKS="${DEP_SCAN_DISABLE_REMOTE_CHECKS:-false}"
--volume "$PWD:/code"
--volume /var/run/docker.sock:/var/run/docker.sock
"registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$SP_VERSION" /code
artifacts:
reports:
dependency_scanning: gl-dependency-scanning-report.json
......@@ -999,7 +1000,7 @@ qa:selectors:
variables:
NODE_OPTIONS: --max_old_space_size=3584
cache:
key: "$CI_JOB_NAME"
key: '$CI_JOB_NAME'
paths:
- .yarn-cache/
dependencies: []
......@@ -1035,7 +1036,7 @@ coverage:
<<: *except-docs-and-qa
<<: *pull-cache
variables:
SETUP_DB: "false"
SETUP_DB: 'false'
stage: post-test
script:
- bundle exec scripts/merge-simplecov
......@@ -1044,8 +1045,8 @@ coverage:
name: coverage
expire_in: 31d
paths:
- coverage/index.html
- coverage/assets/
- coverage/index.html
- coverage/assets/
lint:javascript:report:
<<: *dedicated-no-docs-and-no-qa-pull-cache-job
......@@ -1104,7 +1105,7 @@ gitlab_git_test:
<<: *dedicated-runner
<<: *except-docs-and-qa
variables:
SETUP_DB: "false"
SETUP_DB: 'false'
before_script: []
dependencies: []
cache: {}
......@@ -1115,7 +1116,7 @@ no_ee_check:
<<: *dedicated-runner
<<: *except-docs-and-qa
variables:
SETUP_DB: "false"
SETUP_DB: 'false'
before_script: []
dependencies: []
cache: {}
......@@ -1130,11 +1131,11 @@ review-deploy:
retry: 2
allow_failure: true
variables:
GIT_DEPTH: "1"
HOST_SUFFIX: "${CI_ENVIRONMENT_SLUG}"
DOMAIN: "-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}"
GITLAB_HELM_CHART_REF: "master"
API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}"
GIT_DEPTH: '1'
HOST_SUFFIX: '${CI_ENVIRONMENT_SLUG}'
DOMAIN: '-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}'
GITLAB_HELM_CHART_REF: 'master'
API_TOKEN: '${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}'
environment:
<<: *review-environment
on_stop: review-stop
......@@ -1162,16 +1163,16 @@ review-deploy:
allow_failure: true
variables:
<<: *review-docker-variables
API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}"
QA_ARTIFACTS_DIR: "${CI_PROJECT_DIR}/qa"
QA_CAN_TEST_GIT_PROTOCOL_V2: "false"
GITLAB_USERNAME: "root"
GITLAB_PASSWORD: "${REVIEW_APPS_ROOT_PASSWORD}"
GITLAB_ADMIN_USERNAME: "root"
GITLAB_ADMIN_PASSWORD: "${REVIEW_APPS_ROOT_PASSWORD}"
GITHUB_ACCESS_TOKEN: "${REVIEW_APPS_QA_GITHUB_ACCESS_TOKEN}"
EE_LICENSE: "${REVIEW_APPS_EE_LICENSE}"
QA_DEBUG: "true"
API_TOKEN: '${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}'
QA_ARTIFACTS_DIR: '${CI_PROJECT_DIR}/qa'
QA_CAN_TEST_GIT_PROTOCOL_V2: 'false'
GITLAB_USERNAME: 'root'
GITLAB_PASSWORD: '${REVIEW_APPS_ROOT_PASSWORD}'
GITLAB_ADMIN_USERNAME: 'root'
GITLAB_ADMIN_PASSWORD: '${REVIEW_APPS_ROOT_PASSWORD}'
GITHUB_ACCESS_TOKEN: '${REVIEW_APPS_QA_GITHUB_ACCESS_TOKEN}'
EE_LICENSE: '${REVIEW_APPS_EE_LICENSE}'
QA_DEBUG: 'true'
artifacts:
paths:
- ./qa/gitlab-qa-run-*
......@@ -1203,7 +1204,7 @@ review-stop:
allow_failure: true
variables:
<<: *single-script-job-variables
SCRIPT_NAME: "review_apps/review-apps.sh"
SCRIPT_NAME: 'review_apps/review-apps.sh'
when: manual
environment:
<<: *review-environment
......@@ -1218,7 +1219,7 @@ schedule:review-cleanup:
stage: build
allow_failure: true
variables:
GIT_DEPTH: "1"
GIT_DEPTH: '1'
environment:
name: review/auto-cleanup
only:
......
......@@ -22,7 +22,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',
......@@ -257,6 +259,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));
......@@ -279,7 +295,7 @@ const Api = {
},
postUserStatus({ emoji, message }) {
const url = Api.buildUrl(this.userStatusPath);
const url = Api.buildUrl(this.userPostStatusPath);
return axios.put(url, {
emoji,
......
......@@ -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;
};
......
......@@ -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();
......@@ -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';
// EE-only scripts
import 'ee/main';
......@@ -81,6 +82,7 @@ document.addEventListener('DOMContentLoaded', () => {
initTodoToggle();
initLogoAnimation();
initUsagePingConsent();
initUserPopovers();
if (document.querySelector('.search')) initSearchAutocomplete();
if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
......
......@@ -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" />
......
......@@ -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"
>
<slot name="note-header-info"></slot>
<span class="note-header-author-name">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
......
......@@ -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([
......
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,
};
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);
};
export default elements => {
let userLinks = elements;
if (!elements) {
userLinks = [...document.querySelectorAll('.js-user-link')];
}
userLinks.forEach(el => {
el.addEventListener('mouseenter', handleUserPopoverMouseOver);
});
};
......@@ -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"
......
<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.loaded && this.user.organization === null;
},
locationIsLoading() {
return !this.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>
......@@ -34,6 +34,11 @@
*/
@import "pages/**/*";
/*
* Component specific styles, will be moved to gitlab-ui
*/
@import "components/**/*";
/*
* Code highlight
*/
......
.popover {
min-width: 300px;
.popover-body .user-popover {
padding: $gl-padding-8;
font-size: $gl-font-size-small;
line-height: $gl-line-height;
}
}
......@@ -173,6 +173,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);
......
......@@ -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;
......@@ -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)
......
......@@ -52,6 +52,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 = []
......@@ -67,7 +73,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
......
---
title: Extended user centric tooltips on issue and MR page
merge_request: 23231
author:
type: added
......@@ -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)
......
......@@ -116,6 +116,9 @@ msgstr ""
msgid "%{authorsName}'s discussion"
msgstr ""
msgid "%{bio} at %{organization}"
msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
......
......@@ -355,6 +355,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';
......
......@@ -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);
});
});
});
......@@ -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);
......
......@@ -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', () => {
......
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);
});
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();
});
});
});
......@@ -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 = {
......
......@@ -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() {
......
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"');
});
});
});
......@@ -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
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment