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 .dedicated-runner: &dedicated-runner
retry: 1 retry: 1
...@@ -6,7 +6,7 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.9-git ...@@ -6,7 +6,7 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.9-git
- gitlab-org - gitlab-org
.default-cache: &default-cache .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: paths:
- vendor/ruby - vendor/ruby
- .yarn-cache/ - .yarn-cache/
...@@ -23,20 +23,20 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.9-git ...@@ -23,20 +23,20 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-golang-1.9-git
policy: pull policy: pull
variables: variables:
MYSQL_ALLOW_EMPTY_PASSWORD: "1" MYSQL_ALLOW_EMPTY_PASSWORD: '1'
RAILS_ENV: "test" RAILS_ENV: 'test'
NODE_ENV: "test" NODE_ENV: 'test'
SIMPLECOV: "true" SIMPLECOV: 'true'
GIT_DEPTH: "20" GIT_DEPTH: '20'
GIT_SUBMODULE_STRATEGY: "none" GIT_SUBMODULE_STRATEGY: 'none'
GET_SOURCES_ATTEMPTS: "3" GET_SOURCES_ATTEMPTS: '3'
KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/${CI_PROJECT_NAME}/rspec_report-master.json KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/${CI_PROJECT_NAME}/rspec_report-master.json
FLAKY_RSPEC_SUITE_REPORT_PATH: rspec_flaky/report-suite.json FLAKY_RSPEC_SUITE_REPORT_PATH: rspec_flaky/report-suite.json
BUILD_ASSETS_IMAGE: "false" BUILD_ASSETS_IMAGE: 'false'
## EE specific variables ## ## EE specific variables ##
# This hack is needed to make ES not that memory hungry # This hack is needed to make ES not that memory hungry
ES_JAVA_OPTS: "-Xms256m -Xmx256m" ES_JAVA_OPTS: '-Xms256m -Xmx256m'
ELASTIC_URL: "http://elastic:changeme@docker.elastic.co-elasticsearch-elasticsearch:9200" 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 EE_KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/${CI_PROJECT_NAME}/rspec_report-master-ee.json
before_script: before_script:
...@@ -63,7 +63,7 @@ stages: ...@@ -63,7 +63,7 @@ stages:
.tests-metadata-state: &tests-metadata-state .tests-metadata-state: &tests-metadata-state
<<: *dedicated-runner <<: *dedicated-runner
variables: variables:
TESTS_METADATA_S3_BUCKET: "gitlab-ce-cache" TESTS_METADATA_S3_BUCKET: 'gitlab-ce-cache'
before_script: before_script:
- source scripts/utils.sh - source scripts/utils.sh
artifacts: artifacts:
...@@ -117,8 +117,8 @@ stages: ...@@ -117,8 +117,8 @@ stages:
- $CI_COMMIT_REF_NAME =~ /norails4/ - $CI_COMMIT_REF_NAME =~ /norails4/
- $RAILS5_DISABLED - $RAILS5_DISABLED
variables: variables:
BUNDLE_GEMFILE: "Gemfile.rails4" BUNDLE_GEMFILE: 'Gemfile.rails4'
RAILS5: "false" RAILS5: 'false'
# Skip all jobs except the ones that begin with 'docs/'. # Skip all jobs except the ones that begin with 'docs/'.
# Used for commits including ONLY documentation changes. # Used for commits including ONLY documentation changes.
...@@ -149,7 +149,7 @@ stages: ...@@ -149,7 +149,7 @@ stages:
.dedicated-no-docs-no-db-pull-cache-job: &dedicated-no-docs-no-db-pull-cache-job .dedicated-no-docs-no-db-pull-cache-job: &dedicated-no-docs-no-db-pull-cache-job
<<: *dedicated-no-docs-pull-cache-job <<: *dedicated-no-docs-pull-cache-job
variables: 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-and-no-qa-pull-cache-job: &dedicated-no-docs-and-no-qa-pull-cache-job
<<: *dedicated-no-docs-pull-cache-job <<: *dedicated-no-docs-pull-cache-job
...@@ -292,12 +292,12 @@ stages: ...@@ -292,12 +292,12 @@ stages:
<<: *dedicated-no-docs-and-no-qa-pull-cache-job <<: *dedicated-no-docs-and-no-qa-pull-cache-job
<<: *use-pg <<: *use-pg
variables: variables:
SETUP_DB: "false" SETUP_DB: 'false'
script: script:
# Manually clone gitlab-test and only seed this project in # Manually clone gitlab-test and only seed this project in
# db/fixtures/development/04_project.rb thanks to SIZE=1 below # db/fixtures/development/04_project.rb thanks to SIZE=1 below
- git clone https://gitlab.com/gitlab-org/gitlab-test.git - 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 - scripts/gitaly-test-spawn
- force=yes SIZE=1 FIXTURE_PATH="db/fixtures/development" bundle exec rake gitlab:setup - force=yes SIZE=1 FIXTURE_PATH="db/fixtures/development" bundle exec rake gitlab:setup
artifacts: artifacts:
...@@ -315,7 +315,7 @@ stages: ...@@ -315,7 +315,7 @@ stages:
.migration-paths: &migration-paths .migration-paths: &migration-paths
<<: *dedicated-no-docs-and-no-qa-pull-cache-job <<: *dedicated-no-docs-and-no-qa-pull-cache-job
variables: variables:
SETUP_DB: "false" SETUP_DB: 'false'
script: script:
- git fetch https://gitlab.com/gitlab-org/gitlab-ee.git v9.3.0-ee - git fetch https://gitlab.com/gitlab-org/gitlab-ee.git v9.3.0-ee
- git checkout -f FETCH_HEAD - git checkout -f FETCH_HEAD
...@@ -336,7 +336,7 @@ stages: ...@@ -336,7 +336,7 @@ stages:
.migration-paths-upgrade-ce-to-ee: &migration-paths-upgrade-ce-to-ee .migration-paths-upgrade-ce-to-ee: &migration-paths-upgrade-ce-to-ee
<<: *dedicated-no-docs-and-no-qa-pull-cache-job <<: *dedicated-no-docs-and-no-qa-pull-cache-job
variables: variables:
SETUP_DB: "false" SETUP_DB: 'false'
script: script:
- ruby -r./scripts/ee_specific_check/ee_specific_check -e'EESpecificCheck.fetch_remote_ce_branch' - ruby -r./scripts/ee_specific_check/ee_specific_check -e'EESpecificCheck.fetch_remote_ce_branch'
- git checkout -f FETCH_HEAD - git checkout -f FETCH_HEAD
...@@ -434,7 +434,7 @@ cloud-native-image: ...@@ -434,7 +434,7 @@ cloud-native-image:
stage: post-test stage: post-test
allow_failure: true allow_failure: true
variables: variables:
GIT_DEPTH: "1" GIT_DEPTH: '1'
cache: {} cache: {}
script: script:
- gem install gitlab --no-document - gem install gitlab --no-document
...@@ -515,8 +515,8 @@ flaky-examples-check: ...@@ -515,8 +515,8 @@ flaky-examples-check:
services: [] services: []
before_script: [] before_script: []
variables: variables:
SETUP_DB: "false" SETUP_DB: 'false'
USE_BUNDLE_INSTALL: "false" USE_BUNDLE_INSTALL: 'false'
NEW_FLAKY_SPECS_REPORT: rspec_flaky/report-new.json NEW_FLAKY_SPECS_REPORT: rspec_flaky/report-new.json
stage: post-test stage: post-test
allow_failure: true allow_failure: true
...@@ -607,11 +607,11 @@ setup-test-env: ...@@ -607,11 +607,11 @@ setup-test-env:
- gitlab-org - gitlab-org
- docker - docker
variables: &review-docker-variables variables: &review-docker-variables
GIT_DEPTH: "1" GIT_DEPTH: '1'
DOCKER_DRIVER: overlay2 DOCKER_DRIVER: overlay2
DOCKER_HOST: tcp://docker:2375 DOCKER_HOST: tcp://docker:2375
LATEST_QA_IMAGE: "gitlab/${CI_PROJECT_NAME}-qa:nightly" 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}" QA_IMAGE: '${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab/${CI_PROJECT_NAME}-qa:${CI_COMMIT_REF_SLUG}'
build-qa-image: build-qa-image:
<<: *review-docker <<: *review-docker
...@@ -691,7 +691,7 @@ static-analysis: ...@@ -691,7 +691,7 @@ static-analysis:
script: script:
- scripts/static-analysis - scripts/static-analysis
cache: 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: paths:
- vendor/ruby - vendor/ruby
- .yarn-cache/ - .yarn-cache/
...@@ -703,10 +703,10 @@ static-analysis: ...@@ -703,10 +703,10 @@ static-analysis:
docs lint: docs lint:
<<: *dedicated-runner <<: *dedicated-runner
<<: *except-qa <<: *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 stage: test
variables: variables:
SETUP_DB: "false" SETUP_DB: 'false'
cache: {} cache: {}
dependencies: [] dependencies: []
before_script: [] before_script: []
...@@ -718,7 +718,8 @@ docs lint: ...@@ -718,7 +718,8 @@ docs lint:
# Build HTML from Markdown # Build HTML from Markdown
- bundle exec nanoc - bundle exec nanoc
# Check the internal links # 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: downtime_check:
<<: *rake-exec <<: *rake-exec
...@@ -747,7 +748,7 @@ ee_compat_check: ...@@ -747,7 +748,7 @@ ee_compat_check:
- branches@gitlab/gitlab-ee - branches@gitlab/gitlab-ee
retry: 0 retry: 0
artifacts: 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 when: always
expire_in: 10d expire_in: 10d
paths: paths:
...@@ -820,11 +821,11 @@ gitlab:assets:compile: ...@@ -820,11 +821,11 @@ gitlab:assets:compile:
services: services:
- docker:stable-dind - docker:stable-dind
variables: variables:
NODE_ENV: "production" NODE_ENV: 'production'
RAILS_ENV: "production" RAILS_ENV: 'production'
SETUP_DB: "false" SETUP_DB: 'false'
SKIP_STORAGE_VALIDATION: "true" SKIP_STORAGE_VALIDATION: 'true'
WEBPACK_REPORT: "true" WEBPACK_REPORT: 'true'
# we override the max_old_space_size to prevent OOM errors # we override the max_old_space_size to prevent OOM errors
NODE_OPTIONS: --max_old_space_size=3584 NODE_OPTIONS: --max_old_space_size=3584
DOCKER_DRIVER: overlay2 DOCKER_DRIVER: overlay2
...@@ -881,8 +882,8 @@ jest: ...@@ -881,8 +882,8 @@ jest:
<<: *dedicated-no-docs-and-no-qa-pull-cache-job <<: *dedicated-no-docs-and-no-qa-pull-cache-job
<<: *use-pg <<: *use-pg
dependencies: dependencies:
- compile-assets - compile-assets
- setup-test-env - setup-test-env
script: script:
- scripts/gitaly-test-spawn - scripts/gitaly-test-spawn
- date - date
...@@ -894,8 +895,8 @@ jest: ...@@ -894,8 +895,8 @@ jest:
expire_in: 31d expire_in: 31d
when: always when: always
paths: paths:
- coverage-frontend/ - coverage-frontend/
- junit_jest.xml - junit_jest.xml
reports: reports:
junit: junit_jest.xml junit: junit_jest.xml
cache: cache:
...@@ -914,7 +915,7 @@ code_quality: ...@@ -914,7 +915,7 @@ code_quality:
services: services:
- docker:stable-dind - docker:stable-dind
variables: variables:
SETUP_DB: "false" SETUP_DB: 'false'
DOCKER_DRIVER: overlay2 DOCKER_DRIVER: overlay2
cache: {} cache: {}
dependencies: [] dependencies: []
...@@ -922,10 +923,10 @@ code_quality: ...@@ -922,10 +923,10 @@ code_quality:
# Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Security Products # 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/') - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
- docker run - docker run
--env SOURCE_CODE="$PWD" --env SOURCE_CODE="$PWD"
--volume "$PWD":/code --volume "$PWD":/code
--volume /var/run/docker.sock:/var/run/docker.sock --volume /var/run/docker.sock:/var/run/docker.sock
"registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code
artifacts: artifacts:
reports: reports:
codequality: gl-code-quality-report.json codequality: gl-code-quality-report.json
...@@ -947,10 +948,10 @@ sast: ...@@ -947,10 +948,10 @@ sast:
script: script:
- export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
- docker run - docker run
--env SAST_CONFIDENCE_LEVEL="${SAST_CONFIDENCE_LEVEL:-3}" --env SAST_CONFIDENCE_LEVEL="${SAST_CONFIDENCE_LEVEL:-3}"
--volume "$PWD:/code" --volume "$PWD:/code"
--volume /var/run/docker.sock:/var/run/docker.sock --volume /var/run/docker.sock:/var/run/docker.sock
"registry.gitlab.com/gitlab-org/security-products/sast:$SP_VERSION" /app/bin/run /code "registry.gitlab.com/gitlab-org/security-products/sast:$SP_VERSION" /app/bin/run /code
artifacts: artifacts:
reports: reports:
sast: gl-sast-report.json sast: gl-sast-report.json
...@@ -970,10 +971,10 @@ dependency_scanning: ...@@ -970,10 +971,10 @@ dependency_scanning:
script: script:
- export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
- docker run - docker run
--env DEP_SCAN_DISABLE_REMOTE_CHECKS="${DEP_SCAN_DISABLE_REMOTE_CHECKS:-false}" --env DEP_SCAN_DISABLE_REMOTE_CHECKS="${DEP_SCAN_DISABLE_REMOTE_CHECKS:-false}"
--volume "$PWD:/code" --volume "$PWD:/code"
--volume /var/run/docker.sock:/var/run/docker.sock --volume /var/run/docker.sock:/var/run/docker.sock
"registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$SP_VERSION" /code "registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$SP_VERSION" /code
artifacts: artifacts:
reports: reports:
dependency_scanning: gl-dependency-scanning-report.json dependency_scanning: gl-dependency-scanning-report.json
...@@ -999,7 +1000,7 @@ qa:selectors: ...@@ -999,7 +1000,7 @@ qa:selectors:
variables: variables:
NODE_OPTIONS: --max_old_space_size=3584 NODE_OPTIONS: --max_old_space_size=3584
cache: cache:
key: "$CI_JOB_NAME" key: '$CI_JOB_NAME'
paths: paths:
- .yarn-cache/ - .yarn-cache/
dependencies: [] dependencies: []
...@@ -1035,7 +1036,7 @@ coverage: ...@@ -1035,7 +1036,7 @@ coverage:
<<: *except-docs-and-qa <<: *except-docs-and-qa
<<: *pull-cache <<: *pull-cache
variables: variables:
SETUP_DB: "false" SETUP_DB: 'false'
stage: post-test stage: post-test
script: script:
- bundle exec scripts/merge-simplecov - bundle exec scripts/merge-simplecov
...@@ -1044,8 +1045,8 @@ coverage: ...@@ -1044,8 +1045,8 @@ coverage:
name: coverage name: coverage
expire_in: 31d expire_in: 31d
paths: paths:
- coverage/index.html - coverage/index.html
- coverage/assets/ - coverage/assets/
lint:javascript:report: lint:javascript:report:
<<: *dedicated-no-docs-and-no-qa-pull-cache-job <<: *dedicated-no-docs-and-no-qa-pull-cache-job
...@@ -1104,7 +1105,7 @@ gitlab_git_test: ...@@ -1104,7 +1105,7 @@ gitlab_git_test:
<<: *dedicated-runner <<: *dedicated-runner
<<: *except-docs-and-qa <<: *except-docs-and-qa
variables: variables:
SETUP_DB: "false" SETUP_DB: 'false'
before_script: [] before_script: []
dependencies: [] dependencies: []
cache: {} cache: {}
...@@ -1115,7 +1116,7 @@ no_ee_check: ...@@ -1115,7 +1116,7 @@ no_ee_check:
<<: *dedicated-runner <<: *dedicated-runner
<<: *except-docs-and-qa <<: *except-docs-and-qa
variables: variables:
SETUP_DB: "false" SETUP_DB: 'false'
before_script: [] before_script: []
dependencies: [] dependencies: []
cache: {} cache: {}
...@@ -1130,11 +1131,11 @@ review-deploy: ...@@ -1130,11 +1131,11 @@ review-deploy:
retry: 2 retry: 2
allow_failure: true allow_failure: true
variables: variables:
GIT_DEPTH: "1" GIT_DEPTH: '1'
HOST_SUFFIX: "${CI_ENVIRONMENT_SLUG}" HOST_SUFFIX: '${CI_ENVIRONMENT_SLUG}'
DOMAIN: "-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}" DOMAIN: '-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}'
GITLAB_HELM_CHART_REF: "master" GITLAB_HELM_CHART_REF: 'master'
API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" API_TOKEN: '${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}'
environment: environment:
<<: *review-environment <<: *review-environment
on_stop: review-stop on_stop: review-stop
...@@ -1162,16 +1163,16 @@ review-deploy: ...@@ -1162,16 +1163,16 @@ review-deploy:
allow_failure: true allow_failure: true
variables: variables:
<<: *review-docker-variables <<: *review-docker-variables
API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" API_TOKEN: '${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}'
QA_ARTIFACTS_DIR: "${CI_PROJECT_DIR}/qa" QA_ARTIFACTS_DIR: '${CI_PROJECT_DIR}/qa'
QA_CAN_TEST_GIT_PROTOCOL_V2: "false" QA_CAN_TEST_GIT_PROTOCOL_V2: 'false'
GITLAB_USERNAME: "root" GITLAB_USERNAME: 'root'
GITLAB_PASSWORD: "${REVIEW_APPS_ROOT_PASSWORD}" GITLAB_PASSWORD: '${REVIEW_APPS_ROOT_PASSWORD}'
GITLAB_ADMIN_USERNAME: "root" GITLAB_ADMIN_USERNAME: 'root'
GITLAB_ADMIN_PASSWORD: "${REVIEW_APPS_ROOT_PASSWORD}" GITLAB_ADMIN_PASSWORD: '${REVIEW_APPS_ROOT_PASSWORD}'
GITHUB_ACCESS_TOKEN: "${REVIEW_APPS_QA_GITHUB_ACCESS_TOKEN}" GITHUB_ACCESS_TOKEN: '${REVIEW_APPS_QA_GITHUB_ACCESS_TOKEN}'
EE_LICENSE: "${REVIEW_APPS_EE_LICENSE}" EE_LICENSE: '${REVIEW_APPS_EE_LICENSE}'
QA_DEBUG: "true" QA_DEBUG: 'true'
artifacts: artifacts:
paths: paths:
- ./qa/gitlab-qa-run-* - ./qa/gitlab-qa-run-*
...@@ -1203,7 +1204,7 @@ review-stop: ...@@ -1203,7 +1204,7 @@ review-stop:
allow_failure: true allow_failure: true
variables: variables:
<<: *single-script-job-variables <<: *single-script-job-variables
SCRIPT_NAME: "review_apps/review-apps.sh" SCRIPT_NAME: 'review_apps/review-apps.sh'
when: manual when: manual
environment: environment:
<<: *review-environment <<: *review-environment
...@@ -1218,7 +1219,7 @@ schedule:review-cleanup: ...@@ -1218,7 +1219,7 @@ schedule:review-cleanup:
stage: build stage: build
allow_failure: true allow_failure: true
variables: variables:
GIT_DEPTH: "1" GIT_DEPTH: '1'
environment: environment:
name: review/auto-cleanup name: review/auto-cleanup
only: only:
......
...@@ -22,7 +22,9 @@ const Api = { ...@@ -22,7 +22,9 @@ const Api = {
projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key',
projectTemplatesPath: '/api/:version/projects/:id/templates/:type', projectTemplatesPath: '/api/:version/projects/:id/templates/:type',
usersPath: '/api/:version/users.json', 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', commitPath: '/api/:version/projects/:id/repository/commits',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines', commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
...@@ -257,6 +259,20 @@ const Api = { ...@@ -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 = {}) { branches(id, query = '', options = {}) {
const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
...@@ -279,7 +295,7 @@ const Api = { ...@@ -279,7 +295,7 @@ const Api = {
}, },
postUserStatus({ emoji, message }) { postUserStatus({ emoji, message }) {
const url = Api.buildUrl(this.userStatusPath); const url = Api.buildUrl(this.userPostStatusPath);
return axios.put(url, { return axios.put(url, {
emoji, emoji,
......
...@@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight'; ...@@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight';
import renderMath from './render_math'; import renderMath from './render_math';
import renderMermaid from './render_mermaid'; import renderMermaid from './render_mermaid';
import highlightCurrentUser from './highlight_current_user'; import highlightCurrentUser from './highlight_current_user';
import initUserPopovers from '../../user_popovers';
// Render GitLab flavoured Markdown // Render GitLab flavoured Markdown
// //
...@@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() { ...@@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() {
renderMath(this.find('.js-render-math')); renderMath(this.find('.js-render-math'));
renderMermaid(this.find('.js-render-mermaid')); renderMermaid(this.find('.js-render-mermaid'));
highlightCurrentUser(this.find('.gfm-project_member').get()); highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.gfm-project_member').get());
return this; return this;
}; };
......
...@@ -22,6 +22,34 @@ class UsersCache extends Cache { ...@@ -22,6 +22,34 @@ class UsersCache extends Cache {
}); });
// missing catch is intentional, error handling depends on use case // 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(); export default new UsersCache();
...@@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent'; ...@@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar'; import initPerformanceBar from './performance_bar';
import initSearchAutocomplete from './search_autocomplete'; import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors'; import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
// EE-only scripts // EE-only scripts
import 'ee/main'; import 'ee/main';
...@@ -81,6 +82,7 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -81,6 +82,7 @@ document.addEventListener('DOMContentLoaded', () => {
initTodoToggle(); initTodoToggle();
initLogoAnimation(); initLogoAnimation();
initUsagePingConsent(); initUsagePingConsent();
initUserPopovers();
if (document.querySelector('.search')) initSearchAutocomplete(); if (document.querySelector('.search')) initSearchAutocomplete();
if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
......
...@@ -39,7 +39,10 @@ export default { ...@@ -39,7 +39,10 @@ export default {
<div :class="className"> <div :class="className">
{{ actionText }} {{ actionText }}
<template v-if="editedBy"> <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> </template>
{{ actionDetailText }} {{ actionDetailText }}
<time-ago-tooltip :time="editedAt" tooltip-placement="bottom" /> <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
......
...@@ -73,7 +73,14 @@ export default { ...@@ -73,7 +73,14 @@ export default {
{{ __('Toggle discussion') }} {{ __('Toggle discussion') }}
</button> </button>
</div> </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> <slot name="note-header-info"></slot>
<span class="note-header-author-name">{{ author.name }}</span> <span class="note-header-author-name">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></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. ...@@ -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 placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import initUserPopovers from '../../user_popovers';
export default { export default {
name: 'NotesApp', name: 'NotesApp',
...@@ -106,7 +107,10 @@ export default { ...@@ -106,7 +107,10 @@ export default {
} }
}, },
updated() { 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: { methods: {
...mapActions([ ...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 { ...@@ -67,7 +67,7 @@ export default {
// In both cases we should render the defaultAvatarUrl // In both cases we should render the defaultAvatarUrl
sanitizedSource() { sanitizedSource() {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; 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; return baseSrc;
}, },
resultantSrcAttribute() { resultantSrcAttribute() {
...@@ -97,6 +97,7 @@ export default { ...@@ -97,6 +97,7 @@ export default {
class="avatar" class="avatar"
/> />
<gl-tooltip <gl-tooltip
v-if="tooltipText || $slots.default"
:target="() => $refs.userAvatarImage" :target="() => $refs.userAvatarImage"
:placement="tooltipPlacement" :placement="tooltipPlacement"
boundary="window" 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 @@ ...@@ -34,6 +34,11 @@
*/ */
@import "pages/**/*"; @import "pages/**/*";
/*
* Component specific styles, will be moved to gitlab-ui
*/
@import "components/**/*";
/* /*
* Code highlight * 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; ...@@ -173,6 +173,7 @@ $theme-light-red-700: #a62e21;
$black: #000; $black: #000;
$black-transparent: rgba(0, 0, 0, 0.3); $black-transparent: rgba(0, 0, 0, 0.3);
$shadow-color: rgba($black, 0.1);
$almost-black: #242424; $almost-black: #242424;
$border-white-light: darken($white-light, $darken-border-factor); $border-white-light: darken($white-light, $darken-border-factor);
......
...@@ -21,3 +21,8 @@ $danger: $red-500; ...@@ -21,3 +21,8 @@ $danger: $red-500;
$zindex-modal-backdrop: 1040; $zindex-modal-backdrop: 1040;
$nav-divider-margin-y: ($grid-size / 2); $nav-divider-margin-y: ($grid-size / 2);
$dropdown-divider-bg: $theme-gray-200; $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 ...@@ -179,7 +179,7 @@ module IssuablesHelper
output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
output << content_tag(:strong) do 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") 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) if status = user_status(issuable.author)
......
...@@ -52,6 +52,12 @@ module ProjectsHelper ...@@ -52,6 +52,12 @@ module ProjectsHelper
default_opts = { avatar: true, name: true, title: ":name" } default_opts = { avatar: true, name: true, title: ":name" }
opts = default_opts.merge(opts) opts = default_opts.merge(opts)
data_attrs = {
user_id: author.id,
username: author.username,
name: author.name
}
return "(deleted)" unless author return "(deleted)" unless author
author_html = [] author_html = []
...@@ -67,7 +73,7 @@ module ProjectsHelper ...@@ -67,7 +73,7 @@ module ProjectsHelper
author_html = author_html.join.html_safe author_html = author_html.join.html_safe
if opts[:name] 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 else
title = opts[:title].sub(":name", sanitize(author.name)) 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 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 ...@@ -106,7 +106,7 @@ module Banzai
end end
def link_class def link_class
reference_class(:project_member) reference_class(:project_member, tooltip: false)
end end
def link_to_all(link_content: nil) def link_to_all(link_content: nil)
......
...@@ -116,6 +116,9 @@ msgstr "" ...@@ -116,6 +116,9 @@ msgstr ""
msgid "%{authorsName}'s discussion" msgid "%{authorsName}'s discussion"
msgstr "" msgstr ""
msgid "%{bio} at %{organization}"
msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}" msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr "" msgstr ""
......
...@@ -355,6 +355,40 @@ describe('Api', () => { ...@@ -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', () => { describe('commitPipelines', () => {
it('fetches pipelines for a given commit', done => { it('fetches pipelines for a given commit', done => {
const projectId = 'example/foobar'; const projectId = 'example/foobar';
......
...@@ -3,7 +3,9 @@ import UsersCache from '~/lib/utils/users_cache'; ...@@ -3,7 +3,9 @@ import UsersCache from '~/lib/utils/users_cache';
describe('UsersCache', () => { describe('UsersCache', () => {
const dummyUsername = 'win'; const dummyUsername = 'win';
const dummyUser = 'has a farm'; const dummyUserId = 123;
const dummyUser = { name: 'has a farm', username: 'farmer' };
const dummyUserStatus = 'my status';
beforeEach(() => { beforeEach(() => {
UsersCache.internalStorage = {}; UsersCache.internalStorage = {};
...@@ -135,4 +137,110 @@ describe('UsersCache', () => { ...@@ -135,4 +137,110 @@ describe('UsersCache', () => {
.catch(done.fail); .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', () => { ...@@ -39,7 +39,7 @@ describe('note_edited_text', () => {
}); });
it('should render provided user information', () => { 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.getAttribute('href')).toEqual(props.editedBy.path);
expect(authorLink.textContent.trim()).toEqual(props.editedBy.name); expect(authorLink.textContent.trim()).toEqual(props.editedBy.name);
......
...@@ -42,6 +42,9 @@ describe('note_header component', () => { ...@@ -42,6 +42,9 @@ describe('note_header component', () => {
it('should render user information', () => { it('should render user information', () => {
expect(vm.$el.querySelector('.note-header-author-name').textContent.trim()).toEqual('Root'); 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').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', () => { 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'; ...@@ -2,6 +2,7 @@ import Vue from 'vue';
import { placeholderImage } from '~/lazy_loader'; import { placeholderImage } from '~/lazy_loader';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper'; import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
import defaultAvatarUrl from '~/../images/no_avatar.png';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
size: 99, size: 99,
...@@ -76,6 +77,18 @@ describe('User Avatar Image Component', function() { ...@@ -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', () => { describe('dynamic tooltip content', () => {
const props = DEFAULT_PROPS; const props = DEFAULT_PROPS;
const slots = { const slots = {
......
...@@ -74,9 +74,7 @@ describe('User Avatar Link Component', function() { ...@@ -74,9 +74,7 @@ describe('User Avatar Link Component', function() {
describe('username', function() { describe('username', function() {
it('should not render avatar image tooltip', function() { it('should not render avatar image tooltip', function() {
expect( expect(this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip')).toBeNull();
this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip').innerText.trim(),
).toEqual('');
}); });
it('should render username prop in <span>', function() { 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 ...@@ -120,7 +120,7 @@ describe Banzai::Filter::UserReferenceFilter do
it 'includes default classes' do it 'includes default classes' do
doc = reference_filter("Hey #{reference}") 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 end
context 'when a project is not specified' do 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