Commit 5c29be3a authored by Stan Hu's avatar Stan Hu

Merge branch 'master' into ce-to-ee-2018-12-07

parents 471b703d f9f8ebea
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;
};
......
......@@ -15,6 +15,16 @@ export default {
type: String,
required: true,
},
cssClass: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'bottom',
},
},
computed: {
title() {
......@@ -66,15 +76,13 @@ export default {
<template>
<span>
<span ref="issueDueDate" class="board-card-info card-number">
<icon
:class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }"
name="calendar"
/><time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
<span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
<icon :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" name="calendar" />
<time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
body
}}</time>
</span>
<gl-tooltip :target="() => $refs.issueDueDate" placement="bottom">
<gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement">
<span class="bold">{{ __('Due date') }}</span> <br />
<span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span>
</gl-tooltip>
......
......@@ -4,6 +4,7 @@ import _ from 'underscore';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
import { GlLoadingIcon } from '@gitlab/ui';
import eventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
......@@ -75,6 +76,9 @@ export default {
}
},
},
created() {
eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff);
},
methods: {
...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff']),
handleToggle() {
......
......@@ -3,8 +3,9 @@ import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils';
import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
import eventHub from '../../notes/event_hub';
import { getDiffPositionByLineCode, getNoteFormData } from './utils';
import * as types from './mutation_types';
import {
......@@ -53,6 +54,10 @@ export const assignDiscussionsToDiff = (
diffPositionByLineCode,
});
});
Vue.nextTick(() => {
eventHub.$emit('scrollToDiscussion');
});
};
export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
......@@ -60,6 +65,27 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
};
export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => {
const discussion = rootState.notes.discussions.find(d => d.id === discussionId);
if (discussion) {
const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash);
if (file) {
if (!file.renderIt) {
commit(types.RENDER_FILE, file);
}
if (file.collapsed) {
eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
scrollToElement(document.getElementById(file.file_hash));
} else {
eventHub.$emit('scrollToDiscussion');
}
}
}
};
export const startRenderDiffsQueue = ({ state, commit }) => {
const checkItem = () =>
new Promise(resolve => {
......
......@@ -170,7 +170,7 @@ export default {
}
if (!file.parallel_diff_lines || !file.highlighted_diff_lines) {
file.discussions = file.discussions.concat(discussion);
file.discussions = (file.discussions || []).concat(discussion);
}
return file;
......
......@@ -192,8 +192,12 @@ export const contentTop = () => {
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
const diffFilesChanged = $('.js-diff-files-changed').height() || 0;
const diffFileLargeEnoughScreen =
'matchMedia' in window ? window.matchMedia('min-width: 768') : true;
const diffFileTitleBar =
(diffFileLargeEnoughScreen && $('.diff-file .file-title-flex-parent:visible').height()) || 0;
return perfBar + mrTabsHeight + headerHeight + diffFilesChanged;
return perfBar + mrTabsHeight + headerHeight + diffFilesChanged + diffFileTitleBar;
};
export const scrollToElement = element => {
......
......@@ -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' });
......
<script>
import { GlAreaChart } from '@gitlab/ui';
import dateFormat from 'dateformat';
export default {
components: {
GlAreaChart,
},
props: {
graphData: {
type: Object,
required: true,
validator(data) {
return (
data.queries &&
Array.isArray(data.queries) &&
data.queries.filter(query => {
if (Array.isArray(query.result)) {
return (
query.result.filter(res => Array.isArray(res.values)).length === query.result.length
);
}
return false;
}).length === data.queries.length
);
},
},
},
computed: {
chartData() {
return this.graphData.queries.reduce((accumulator, query) => {
const xLabel = `${query.unit}`;
accumulator[xLabel] = {};
query.result.forEach(res =>
res.values.forEach(v => {
accumulator[xLabel][v.time.toISOString()] = v.value;
}),
);
return accumulator;
}, {});
},
chartOptions() {
return {
xAxis: {
name: 'Time',
type: 'time',
axisLabel: {
formatter: date => dateFormat(date, 'h:MMtt'),
},
nameTextStyle: {
padding: [18, 0, 0, 0],
},
},
yAxis: {
name: this.graphData.y_label,
axisLabel: {
formatter: value => value.toFixed(3),
},
nameTextStyle: {
padding: [0, 0, 36, 0],
},
},
legend: {
formatter: this.xAxisLabel,
},
};
},
xAxisLabel() {
return this.graphData.queries.map(query => query.label).join(', ');
},
},
methods: {
formatTooltipText(params) {
const [date, value] = params;
return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)];
},
onCreated(chart) {
this.$emit('created', chart);
},
},
};
</script>
<template>
<div class="prometheus-graph">
<div class="prometheus-graph-header">
<h5 class="prometheus-graph-title">{{ graphData.title }}</h5>
<div class="prometheus-graph-widgets"><slot></slot></div>
</div>
<gl-area-chart
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
@created="onCreated"
/>
</div>
</template>
......@@ -7,6 +7,7 @@ import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash';
import MonitoringService from '../services/monitoring_service';
import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue';
import Graph from './graph.vue';
import EmptyState from './empty_state.vue';
......@@ -15,6 +16,7 @@ import eventHub from '../event_hub';
export default {
components: {
MonitorAreaChart,
Graph,
GraphGroup,
EmptyState,
......@@ -114,6 +116,9 @@ export default {
};
},
computed: {
graphComponent() {
return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph;
},
forceRedraw() {
return this.elWidth;
},
......@@ -229,7 +234,8 @@ export default {
:name="groupData.group"
:show-panels="showPanels"
>
<graph
<component
:is="graphComponent"
v-for="(graphData, graphIndex) in groupData.metrics"
:key="graphIndex"
:graph-data="graphData"
......@@ -259,7 +265,7 @@ export default {
:alert-data="alertData[graphData.id]"
@setAlerts="setAlerts"
/>
</graph>
</component>
</graph-group>
</div>
<empty-state
......
......@@ -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>
......
......@@ -91,6 +91,7 @@ export default {
'nextUnresolvedDiscussionId',
'unresolvedDiscussionsCount',
'hasUnresolvedDiscussions',
'showJumpToNextDiscussion',
]),
author() {
return this.initialDiscussion.author;
......@@ -131,6 +132,12 @@ export default {
resolvedText() {
return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
},
shouldShowJumpToNextDiscussion() {
return this.showJumpToNextDiscussion(
this.discussion.id,
this.discussionsByDiffOrder ? 'diff' : 'discussion',
);
},
shouldRenderDiffs() {
return this.discussion.diff_discussion && this.renderDiffFile;
},
......@@ -433,7 +440,7 @@ Please check your network connection and try again.`;
<icon name="issue-new" />
</a>
</div>
<div v-if="hasUnresolvedDiscussions" class="btn-group" role="group">
<div v-if="shouldShowJumpToNextDiscussion" class="btn-group" role="group">
<button
v-gl-tooltip
class="btn btn-default discussion-next-btn"
......
......@@ -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 { scrollToElement } from '~/lib/utils/common_utils';
import eventHub from '../../notes/event_hub';
export default {
methods: {
jumpToDiscussion(id) {
if (id) {
const activeTab = window.mrTabs.currentAction;
const selector =
activeTab === 'diffs'
? `ul.notes[data-discussion-id="${id}"]`
: `div.discussion[data-discussion-id="${id}"]`;
const el = document.querySelector(selector);
diffsJump(id) {
const selector = `ul.notes[data-discussion-id="${id}"]`;
if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.activateTab('show');
}
eventHub.$once('scrollToDiscussion', () => {
const el = document.querySelector(selector);
if (el) {
this.expandDiscussion({ discussionId: id });
scrollToElement(el);
return true;
}
return false;
});
this.expandDiscussion({ discussionId: id });
},
discussionJump(id) {
const selector = `div.discussion[data-discussion-id="${id}"]`;
const el = document.querySelector(selector);
this.expandDiscussion({ discussionId: id });
if (el) {
scrollToElement(el);
return true;
}
return false;
},
jumpToDiscussion(id) {
if (id) {
const activeTab = window.mrTabs.currentAction;
if (activeTab === 'diffs') {
this.diffsJump(id);
} else if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.eventHub.$once('MergeRequestTabChange', () => {
setTimeout(() => this.discussionJump(id), 0);
});
window.mrTabs.tabShown('show');
} else {
this.discussionJump(id);
}
}
},
},
};
......@@ -17,7 +17,13 @@ import { __ } from '~/locale';
let eTagPoll;
export const expandDiscussion = ({ commit }, data) => commit(types.EXPAND_DISCUSSION, data);
export const expandDiscussion = ({ commit, dispatch }, data) => {
if (data.discussionId) {
dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true });
}
commit(types.EXPAND_DISCUSSION, data);
};
export const collapseDiscussion = ({ commit }, data) => commit(types.COLLAPSE_DISCUSSION, data);
......
......@@ -57,6 +57,17 @@ export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCo
export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount;
export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions;
export const showJumpToNextDiscussion = (state, getters) => (discussionId, mode = 'discussion') => {
const orderedDiffs =
mode !== 'discussion'
? getters.unresolvedDiscussionsIdsByDiff
: getters.unresolvedDiscussionsIdsByDate;
const indexOf = orderedDiffs.indexOf(discussionId);
return indexOf !== -1 && indexOf < orderedDiffs.length - 1;
};
export const isDiscussionResolved = (state, getters) => discussionId =>
getters.resolvedDiscussionsById[discussionId] !== undefined;
......@@ -104,7 +115,7 @@ export const unresolvedDiscussionsIdsByDate = (state, getters) =>
// line numbers.
export const unresolvedDiscussionsIdsByDiff = (state, getters) =>
getters.allResolvableDiscussions
.filter(d => !d.resolved)
.filter(d => !d.resolved && d.active)
.sort((a, b) => {
if (!a.diff_file || !b.diff_file) {
return 0;
......
......@@ -22,6 +22,7 @@ export default {
if (isDiscussion && isInMRPage()) {
noteData.resolvable = note.resolvable;
noteData.resolved = false;
noteData.active = true;
noteData.resolve_path = note.resolve_path;
noteData.resolve_with_issue_path = note.resolve_with_issue_path;
noteData.diff_discussion = false;
......
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);
});
};
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
components: {
UserAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
assignees: {
type: Array,
required: true,
},
},
data() {
return {
maxVisibleAssignees: 2,
maxAssigneeAvatars: 3,
maxAssignees: 99,
};
},
computed: {
countOverLimit() {
return this.assignees.length - this.maxVisibleAssignees;
},
assigneesToShow() {
if (this.assignees.length > this.maxAssigneeAvatars) {
return this.assignees.slice(0, this.maxVisibleAssignees);
}
return this.assignees;
},
assigneesCounterTooltip() {
const { countOverLimit, maxAssignees } = this;
const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit;
return sprintf(__('%{count} more assignees'), { count });
},
shouldRenderAssigneesCounter() {
const assigneesCount = this.assignees.length;
if (assigneesCount <= this.maxAssigneeAvatars) {
return false;
}
return assigneesCount > this.countOverLimit;
},
assigneeCounterLabel() {
if (this.countOverLimit > this.maxAssignees) {
return `${this.maxAssignees}+`;
}
return `+${this.countOverLimit}`;
},
},
methods: {
avatarUrlTitle(assignee) {
return sprintf(__('Avatar for %{assigneeName}'), {
assigneeName: assignee.name,
});
},
},
};
</script>
<template>
<div class="issue-assignees">
<user-avatar-link
v-for="assignee in assigneesToShow"
:key="assignee.id"
:link-href="assignee.web_url"
:img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar_url"
:img-size="24"
class="js-no-trigger"
tooltip-placement="bottom"
>
<span class="js-assignee-tooltip">
<span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }}
<span class="text-white-50">@{{ assignee.username }}</span>
</span>
</user-avatar-link>
<span
v-if="shouldRenderAssigneesCounter"
v-gl-tooltip
:title="assigneesCounterTooltip"
class="avatar-counter"
data-placement="bottom"
>{{ assigneeCounterLabel }}</span
>
</div>
</template>
<script>
import { GlTooltip } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
GlTooltip,
},
mixins: [timeagoMixin],
props: {
milestone: {
type: Object,
required: true,
},
},
data() {
return {
milestoneDue: this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null,
milestoneStart: this.milestone.start_date
? parsePikadayDate(this.milestone.start_date)
: null,
};
},
computed: {
isMilestoneStarted() {
if (!this.milestoneStart) {
return false;
}
return Date.now() > this.milestoneStart;
},
isMilestonePastDue() {
if (!this.milestoneDue) {
return false;
}
return Date.now() > this.milestoneDue;
},
milestoneDatesAbsolute() {
if (this.milestoneDue) {
return `(${dateInWords(this.milestoneDue)})`;
} else if (this.milestoneStart) {
return `(${dateInWords(this.milestoneStart)})`;
}
return '';
},
milestoneDatesHuman() {
if (this.milestoneStart || this.milestoneDue) {
if (this.milestoneDue) {
return timeFor(
this.milestoneDue,
sprintf(__('Expired %{expiredOn}'), {
expiredOn: this.timeFormated(this.milestoneDue),
}),
);
}
return sprintf(
this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'),
{
startsIn: this.timeFormated(this.milestoneStart),
},
);
}
return '';
},
},
};
</script>
<template>
<div ref="milestoneDetails" class="issue-milestone-details">
<icon :size="16" class="inline icon" name="clock" />
<span class="milestone-title">{{ milestone.title }}</span>
<gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone">
<span class="bold">{{ __('Milestone') }}</span> <br />
<span>{{ milestone.title }}</span> <br />
<span
v-if="milestoneStart || milestoneDue"
:class="{
'text-danger-muted': isMilestonePastDue,
'text-tertiary': !isMilestonePastDue,
}"
><span>{{ milestoneDatesHuman }}</span
><br /><span>{{ milestoneDatesAbsolute }}</span>
</span>
</gl-tooltip>
</div>
</template>
......@@ -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
*/
......
......@@ -18,8 +18,10 @@ $input-border: $border-color;
$padding-base-vertical: $gl-vert-padding;
$padding-base-horizontal: $gl-padding;
html {
// Override default font size used in bs4
body,
.form-control,
.search form {
// Override default font size used in non-csslab UI
font-size: 14px;
}
......
.popover {
min-width: 300px;
.popover-body .user-popover {
padding: $gl-padding-8;
font-size: $gl-font-size-small;
line-height: $gl-line-height;
}
}
@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim";
......@@ -24,7 +24,7 @@
}
}
table {
&:not(.use-csslab) table {
@extend .table;
}
......
......@@ -135,7 +135,7 @@
width: 100%;
}
.md {
.md:not(.use-csslab) {
&.md-preview-holder {
// Reset ul style types since we're nested inside a ul already
@include bulleted-list;
......
......@@ -368,11 +368,11 @@ code {
* Apply Markdown typography
*
*/
.wiki {
.wiki:not(.use-csslab) {
@include md-typography;
}
.md {
.md:not(.use-csslab) {
@include md-typography;
}
......
......@@ -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;
......@@ -180,7 +180,7 @@ ul.wiki-pages-list.content-list {
}
}
.wiki {
.wiki:not(.use-csslab) {
table {
@include markdown-table;
}
......
......@@ -11,6 +11,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index]
before_action do
push_frontend_feature_flag(:area_chart, project)
end
prepend ::EE::Projects::EnvironmentsController
def index
......
......@@ -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
......
......@@ -69,6 +69,7 @@ class UrlValidator < ActiveModel::EachValidator
ports: [],
allow_localhost: true,
allow_local_network: true,
ascii_only: false,
enforce_user: false
}
end
......
......@@ -36,6 +36,7 @@
= stylesheet_link_tag "print", media: "print"
= stylesheet_link_tag "test", media: "all" if Rails.env.test?
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
= stylesheet_link_tag 'csslab' if Feature.enabled?(:csslab)
= Gon::Base.render_data
......
......@@ -9,6 +9,6 @@
= render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder
%article.file-holder
%article.file-holder{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render 'projects/blob/header', blob: blob
= render 'projects/blob/content', blob: blob
.diff-file.file-holder
.diff-content
- if markup?(@blob.name)
.file-content.wiki
.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(@blob.name, @content, legacy_render_context(params))
- else
.file-content.code.js-syntax-highlight
......
......@@ -2,5 +2,5 @@
- context = legacy_render_context(params)
- unless context[:markdown_engine] == :redcarpet
- context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
.file-content.wiki
.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(blob.name, blob.data, context)
......@@ -6,7 +6,7 @@
= render 'shared/snippets/header'
.project-snippets
%article.file-holder.snippet-file-content
%article.file-holder.snippet-file-content{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render 'shared/snippets/blob'
.row-content-block.top-block.content-component-block
......
......@@ -26,7 +26,7 @@
= (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
.prepend-top-default.append-bottom-default
.wiki
.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render_wiki_content(@page, legacy_render_context(params))
= render 'sidebar'
---
title: Extended user centric tooltips on issue and MR page
merge_request: 23231
author:
type: added
---
title: Fix navigating by unresolved discussions on Merge Request page
merge_request: 22789
author:
type: fixed
......@@ -168,6 +168,7 @@ module Gitlab
config.assets.precompile << "locale/**/app.js"
config.assets.precompile << "emoji_sprites.css"
config.assets.precompile << "errors.css"
config.assets.precompile << "csslab.css"
# Import gitlab-svgs directly from vendored directory
config.assets.paths << "#{config.root}/node_modules/@gitlab/svgs/dist"
......
......@@ -592,9 +592,10 @@
in compiled/distributed product so attribution not needed.
:versions: []
:when: 2018-10-02 19:23:54.840151000 Z
- - :approve
- - :license
- echarts
- :who: Mike Greiling
- Apache 2.0
- :who: Adriel Santiago
:why: https://github.com/apache/incubator-echarts/blob/master/LICENSE
:versions: []
:when: 2018-12-05 22:12:30.550027000 Z
:when: 2018-12-07 20:46:12.421256000 Z
# Elasticsearch integration **[STARTER ONLY]**
> [Introduced][ee-109] in GitLab [Starter][ee] 8.4. Support
> for [Amazon Elasticsearch][aws-elastic] was [introduced][ee-1305] in GitLab
> [Starter][ee] 9.0.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/109 "Elasticsearch Merge Request") in GitLab [Starter](https://about.gitlab.com/pricing/) 8.4. Support
> for [Amazon Elasticsearch](http://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-gsg.html) was [introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1305) in GitLab
> [Starter](https://about.gitlab.com/pricing/) 9.0.
This document describes how to set up Elasticsearch with GitLab. Once enabled,
you'll have the benefit of fast search response times and the advantage of two
......@@ -28,12 +28,12 @@ GitLab from source. Providing detailed information on installing Elasticsearch
is out of the scope of this document.
Once the data is added to the database or repository and [Elasticsearch is
enabled in the admin area](#enable-elasticsearch) the search index will be
enabled in the admin area](#enabling-elasticsearch) the search index will be
updated automatically. Elasticsearch can be installed on the same machine as
GitLab, or on a separate server, or you can use the [Amazon Elasticsearch][aws-elastic]
GitLab, or on a separate server, or you can use the [Amazon Elasticsearch](http://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-gsg.html)
service.
You can follow the steps as described in the [official web site][install] or
You can follow the steps as described in the [official web site](https://www.elastic.co/guide/en/elasticsearch/reference/current/_installation.html "Elasticsearch installation documentation") or
use the packages that are available for your OS.
## Elasticsearch repository indexer (beta)
......@@ -118,7 +118,7 @@ The following Elasticsearch settings are available:
| `Use the new repository indexer (beta)` | Perform repository indexing using [GitLab Elasticsearch Indexer](https://gitlab.com/gitlab-org/gitlab-elasticsearch-indexer). |
| `Search with Elasticsearch enabled` | Enables/disables using Elasticsearch in search. |
| `URL` | The URL to use for connecting to Elasticsearch. Use a comma-separated list to support clustering (e.g., "http://host1, https://host2:9200"). If your Elasticsearch instance is password protected, pass the `username:password` in the URL (e.g., `http://<username>:<password>@<elastic_host>:9200/`). |
| `Using AWS hosted Elasticsearch with IAM credentials` | Sign your Elasticsearch requests using [AWS IAM authorization][aws-iam] or [AWS EC2 Instance Profile Credentials][aws-instance-profile]. The policies must be configured to allow `es:*` actions. |
| `Using AWS hosted Elasticsearch with IAM credentials` | Sign your Elasticsearch requests using [AWS IAM authorization](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html) or [AWS EC2 Instance Profile Credentials](http://docs.aws.amazon.com/codedeploy/latest/userguide/getting-started-create-iam-instance-profile.html#getting-started-create-iam-instance-profile-cli). The policies must be configured to allow `es:*` actions. |
| `AWS Region` | The AWS region your Elasticsearch service is located in. |
| `AWS Access Key` | The AWS access key. |
| `AWS Secret Access Key` | The AWS secret access key. |
......@@ -313,6 +313,38 @@ curl --request POST 'http://localhost:9200/_forcemerge?max_num_segments=5'
Enable Elasticsearch search in **Admin > Settings**. That's it. Enjoy it!
## Tuning
### Deleted documents
Whenever a change or deletion is made to an indexed GitLab object (a merge request description is changed, a file is deleted from the master branch in a repository, a project is deleted, etc), a document in the index is deleted. However, since these are "soft" deletes, the overall number of "deleted documents", and therefore wasted space, increases. Elasticsearch does intelligent merging of segments in order to remove these deleted documents. However, depending on the amount and type of activity in your GitLab installation, it's possible to see as much as 50% wasted space in the index.
In general, we recommend simply letting Elasticseach merge and reclaim space automatically, with the default settings. From [Lucene's Handling of Deleted Documents](https://www.elastic.co/blog/lucenes-handling-of-deleted-documents "Lucene's Handling of Deleted Documents"), _"Overall, besides perhaps decreasing the maximum segment size, it is best to leave Lucene's defaults as-is and not fret too much about when deletes are reclaimed."_
However, some larger installations may wish to tune the merge policy settings:
- Consider reducing the `index.merge.policy.max_merged_segment` size from the default 5 GB to maybe 2 GB or 3 GB. Merging only happens when a segment has at least 50% deletions. Smaller segment sizes will allow merging to happen more frequently.
```bash
curl --request PUT http://localhost:9200/gitlab-production/_settings --data '{
"index" : {
"merge.policy.max_merged_segment": "2gb"
}
}'
```
- You can also adjust `index.merge.policy.reclaim_deletes_weight`, which controls how aggressively deletions are targetd. But this can lead to costly merge decisions, so we recommend not changing this unless you understand the tradeoffs.
```bash
curl --request PUT http://localhost:9200/gitlab-production/_settings --data '{
"index" : {
"merge.policy.reclaim_deletes_weight": "3.0"
}
}'
```
- Do not do a [force merge](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-forcemerge.html "Force Merge") to remove deleted documents. A warning in the [documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-forcemerge.html "Force Merge") states that this can lead to very large segments that may never get reclaimed, and can also cause significant performance or availability issues.
## Troubleshooting
Here are some common pitfalls and how to overcome them:
......@@ -325,7 +357,7 @@ Here are some common pitfalls and how to overcome them:
- **I indexed all the repositories but I can't find anything**
Make sure you indexed all the database data [as stated above](#adding-gitlab-data-to-the-elasticsearch-index).
Make sure you indexed all the database data [as stated above](#adding-gitlabs-data-to-the-elasticsearch-index).
- **I indexed all the repositories but then switched elastic search servers and now I can't find anything**
......@@ -355,7 +387,7 @@ Here are some common pitfalls and how to overcome them:
- Exception `Elasticsearch::Transport::Transport::Errors::BadRequest`
If you have this exception (just like in the case above but the actual message is different) please check if you have the correct Elasticsearch version and you met the other [requirements](#requirements).
If you have this exception (just like in the case above but the actual message is different) please check if you have the correct Elasticsearch version and you met the other [requirements](#system-requirements).
There is also an easy way to check it automatically with `sudo gitlab-rake gitlab:check` command.
- Exception `Elasticsearch::Transport::Transport::Errors::RequestEntityTooLarge`
......@@ -373,13 +405,3 @@ Here are some common pitfalls and how to overcome them:
for this setting ("Maximum Size of HTTP Request Payloads"), based on the size of
the underlying instance.
[ee-1305]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1305
[aws-elastic]: http://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-gsg.html
[aws-iam]: http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
[aws-instance-profile]: http://docs.aws.amazon.com/codedeploy/latest/userguide/getting-started-create-iam-instance-profile.html#getting-started-create-iam-instance-profile-cli
[ee-109]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/109 "Elasticsearch Merge Request"
[elasticsearch]: https://www.elastic.co/products/elasticsearch "Elasticsearch website"
[install]: https://www.elastic.co/guide/en/elasticsearch/reference/current/_installation.html "Elasticsearch installation documentation"
[pkg]: https://about.gitlab.com/downloads/ "Download Omnibus GitLab"
[elastic-settings]: https://www.elastic.co/guide/en/elasticsearch/reference/current/setup-configuration.html#settings "Elasticsearch configuration settings"
[ee]: https://about.gitlab.com/pricing/
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { GlTooltip } from '@gitlab/ui';
import icon from '~/vue_shared/components/icon.vue';
export default {
name: 'IssueCardWeight',
components: {
icon,
},
directives: {
GlTooltip: GlTooltipDirective,
GlTooltip,
},
props: {
weight: {
......@@ -21,16 +19,19 @@ export default {
<template>
<a
v-gl-tooltip
:title="__('Weight')"
ref="itemWeight"
class="board-card-info card-number board-card-weight"
data-container="body"
data-placement="bottom"
tabindex="1"
v-on="$listeners"
>
<icon name="weight" css-classes="board-card-info-icon" /><span class="board-card-info-text">{{
weight
}}</span>
<icon name="weight" css-classes="board-card-info-icon" />
<span class="board-card-info-text"> {{ weight }} </span>
<gl-tooltip
:target="() => $refs.itemWeight"
placement="bottom"
container="body"
class="js-item-weight"
>{{ __('Weight') }}<br /><span class="text-tertiary">{{ weight }}</span>
</gl-tooltip>
</a>
</template>
......@@ -31,6 +31,10 @@ export default {
required: false,
default: false,
},
pathIdSeparator: {
type: String,
required: true,
},
},
data() {
......@@ -135,6 +139,7 @@ export default {
:display-reference="reference"
:can-remove="true"
:is-condensed="true"
:path-id-separator="pathIdSeparator"
event-namespace="pendingIssuable"
/>
</li>
......
<script>
import { __ } from '~/locale';
import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import IssueWeight from 'ee/boards/components/issue_card_weight.vue';
import relatedIssueMixin from '../mixins/related_issues_mixin';
export default {
name: 'IssueItem',
components: {
IssueMilestone,
IssueDueDate,
IssueAssignees,
IssueWeight,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [relatedIssueMixin],
props: {
canReorder: {
......@@ -14,7 +28,14 @@ export default {
},
computed: {
stateTitle() {
return this.isOpen ? __('Open') : __('Closed');
return sprintf(
'<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>',
{
state: this.isOpen ? __('Opened') : __('Closed'),
timeInWords: this.isOpen ? this.createdAtInWords : this.closedAtInWords,
timestamp: this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp,
},
);
},
},
};
......@@ -26,22 +47,70 @@ export default {
'issuable-info-container': !canReorder,
'card-body': canReorder,
}"
class="flex"
class="item-body"
>
<div class="block-truncated append-right-8 d-inline-flex">
<div class="block text-secondary append-right-default">
<div class="item-contents">
<div class="item-title d-flex align-items-center">
<icon
v-if="hasState"
v-tooltip
:css-classes="iconClass"
:name="iconName"
:size="12"
:size="16"
:title="stateTitle"
:aria-label="state"
data-html="true"
/>
<icon
v-if="confidential"
v-gl-tooltip
name="eye-slash"
:size="16"
:title="__('Confidential')"
class="confidential-icon append-right-4"
:aria-label="__('Confidential')"
/>
<a :href="computedPath" class="sortable-link">{{ title }}</a>
</div>
<div class="item-meta">
<div class="d-flex align-items-center item-path-id">
<icon
v-if="hasState"
v-tooltip
:css-classes="iconClass"
:name="iconName"
:size="16"
:title="stateTitle"
:aria-label="state"
data-html="true"
/>
<span v-tooltip :title="itemPath" class="path-id-text">{{ itemPath }}</span>
{{ pathIdSeparator }}{{ itemId }}
</div>
<div class="item-meta-child d-flex align-items-center">
<issue-milestone
v-if="milestone"
:milestone="milestone"
class="d-flex align-items-center item-milestone"
/>
<issue-due-date
v-if="dueDate"
:date="dueDate"
tooltip-placement="top"
css-class="item-due-date d-flex align-items-center"
/>
<issue-weight
v-if="weight"
:weight="weight"
class="item-weight d-flex align-items-center"
/>
</div>
<issue-assignees
v-if="assignees.length"
:assignees="assignees"
class="item-assignees d-inline-flex"
/>
{{ displayReference }}
</div>
<a :href="computedPath" class="issue-token-title-text sortable-link"> {{ title }} </a>
</div>
<button
v-if="canRemove"
......@@ -49,13 +118,12 @@ export default {
v-tooltip
:disabled="removeDisabled"
type="button"
class="btn btn-default js-issue-item-remove-button issue-item-remove-button flex-align-self-center flex-right
qa-remove-issue-button"
class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button"
title="Remove"
aria-label="Remove"
@click="onRemoveRequest"
>
<i class="fa fa-times" aria-hidden="true"> </i>
<icon :size="16" class="btn-item-remove-icon" name="close" />
</button>
</div>
</template>
......@@ -60,6 +60,11 @@ export default {
required: false,
default: '',
},
pathIdSeparator: {
type: String,
required: false,
default: '#',
},
helpPath: {
type: String,
required: false,
......@@ -148,16 +153,13 @@ export default {
{{ title }}
<a v-if="hasHelpPath" :href="helpPath">
<i
class="related-issues-header-help-icon
fa fa-question-circle"
class="related-issues-header-help-icon fa fa-question-circle"
aria-label="Read more about related issues"
>
</i>
></i>
</a>
<div class="d-inline-flex lh-100 align-middle">
<div
class="js-related-issues-header-issue-count
related-issues-header-issue-count issue-count-badge mx-1"
class="js-related-issues-header-issue-count related-issues-header-issue-count issue-count-badge mx-1"
>
<span class="issue-count-badge-count">
<icon name="issues" class="mr-1 text-secondary" /> {{ badgeLabel }}
......@@ -167,13 +169,12 @@ fa fa-question-circle"
v-if="canAdmin"
ref="issueCountBadgeAddButton"
type="button"
class="js-issue-count-badge-add-button
issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
class="js-issue-count-badge-add-button issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
aria-label="Add an issue"
data-placement="top"
@click="toggleAddRelatedIssuesForm"
>
<i class="fa fa-plus" aria-hidden="true"> </i>
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
</div>
</h3>
......@@ -190,6 +191,7 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
:input-value="inputValue"
:pending-references="pendingReferences"
:auto-complete-sources="autoCompleteSources"
:path-id-separator="pathIdSeparator"
/>
</div>
<div
......@@ -206,7 +208,7 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
class="prepend-top-5"
/>
</div>
<ul ref="list" :class="{ 'content-list': !canReorder }" class="flex-list issuable-list">
<ul ref="list" :class="{ 'content-list': !canReorder }" class="related-items-list">
<li
v-for="issue in relatedIssues"
:key="issue.id"
......@@ -217,16 +219,24 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
}"
:data-key="issue.id"
:data-epic-issue-id="issue.epic_issue_id"
class="js-related-issues-token-list-item related-issues-list-item pt-0 pb-0"
class="js-related-issues-token-list-item list-item pt-0 pb-0"
>
<issue-item
:id-key="issue.id"
:display-reference="issue.reference"
:confidential="issue.confidential"
:title="issue.title"
:path="issue.path"
:state="issue.state"
:milestone="issue.milestone"
:due-date="issue.due_date"
:assignees="issue.assignees"
:weight="issue.weight"
:created-at="issue.created_at"
:closed-at="issue.closed_at"
:can-remove="canAdmin"
:can-reorder="canReorder"
:path-id-separator="pathIdSeparator"
event-namespace="relatedIssue"
/>
</li>
......
......@@ -246,6 +246,7 @@ export default {
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources"
:title="title"
path-id-separator="#"
@saveReorder="saveIssueOrder"
/>
</template>
import { formatDate } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import eventHub from '../event_hub';
const mixins = {
......@@ -17,11 +19,20 @@ const mixins = {
type: String,
required: true,
},
pathIdSeparator: {
type: String,
required: true,
},
eventNamespace: {
type: String,
required: false,
default: '',
},
confidential: {
type: Boolean,
required: false,
default: false,
},
title: {
type: String,
required: false,
......@@ -37,6 +48,36 @@ const mixins = {
required: false,
default: '',
},
createdAt: {
type: String,
required: false,
default: '',
},
closedAt: {
type: String,
required: false,
default: '',
},
milestone: {
type: Object,
required: false,
default: () => ({}),
},
dueDate: {
type: String,
required: false,
default: '',
},
assignees: {
type: Array,
required: false,
default: () => [],
},
weight: {
type: Number,
required: false,
default: 0,
},
canRemove: {
type: Boolean,
required: false,
......@@ -49,6 +90,7 @@ const mixins = {
directives: {
tooltip,
},
mixins: [timeagoMixin],
computed: {
hasState() {
return this.state && this.state.length > 0;
......@@ -63,7 +105,7 @@ const mixins = {
return this.title.length > 0;
},
iconName() {
return this.isOpen ? 'issue-open' : 'issue-close';
return this.isOpen ? 'issue-open-m' : 'issue-close';
},
iconClass() {
return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
......@@ -74,6 +116,24 @@ const mixins = {
computedPath() {
return this.path.length ? this.path : null;
},
itemPath() {
return this.displayReference.split(this.pathIdSeparator)[0];
},
itemId() {
return this.displayReference.split(this.pathIdSeparator).pop();
},
createdAtInWords() {
return this.createdAt ? this.timeFormated(this.createdAt) : '';
},
createdAtTimestamp() {
return this.createdAt ? formatDate(new Date(this.createdAt)) : '';
},
closedAtInWords() {
return this.closedAt ? this.timeFormated(this.closedAt) : '';
},
closedAtTimestamp() {
return this.closedAt ? formatDate(new Date(this.closedAt)) : '';
},
},
methods: {
onRemoveRequest() {
......
$item-path-max-width: 160px;
$item-milestone-max-width: 120px;
$item-weight-max-width: 48px;
.related-items-list {
padding: $gl-padding-4;
&,
.list-item:last-child {
margin-bottom: 0;
}
}
.item-body {
display: flex;
position: relative;
align-items: center;
padding: $gl-padding-8;
line-height: $gl-line-height;
.item-contents {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-grow: 1;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed,
.confidential-icon,
.item-milestone .icon,
.item-weight .board-card-info-icon {
min-width: $gl-padding;
cursor: help;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
margin-right: $gl-padding-4;
}
.confidential-icon {
align-self: baseline;
color: $orange-600;
margin-right: $gl-padding-4;
}
.item-title {
flex-basis: 100%;
margin-bottom: $gl-padding-8;
font-size: $gl-font-size-small;
.sortable-link {
max-width: 85%;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: none;
}
}
.item-meta {
display: flex;
flex-wrap: wrap;
flex-basis: 100%;
font-size: $gl-font-size-small;
color: $gl-text-color-secondary;
.item-meta-child {
order: 0;
display: flex;
flex-wrap: wrap;
flex-basis: 100%;
.item-due-date,
.item-weight {
margin-left: $gl-padding-8;
}
.item-milestone,
.item-weight {
cursor: help;
text-decoration: none;
}
.item-milestone {
max-width: $item-milestone-max-width;
}
.item-due-date {
margin-right: 0;
}
.item-weight {
margin-right: 0;
max-width: $item-weight-max-width;
}
}
.item-path-id .path-id-text,
.item-milestone .milestone-title,
.item-due-date,
.item-weight .board-card-info-text {
color: $gl-text-color-secondary;
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.item-path-id {
order: 1;
margin-top: $gl-padding-4;
font-size: $gl-font-size-xs;
.path-id-text {
font-weight: $gl-font-weight-bold;
max-width: $item-path-max-width;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: block;
}
}
.item-milestone .ic-clock {
color: $gl-text-color-tertiary;
margin-right: $gl-padding-4;
}
.item-assignees {
order: 2;
align-self: flex-end;
align-items: center;
margin-left: auto;
.user-avatar-link {
margin-right: -$gl-padding-4;
&:nth-of-type(1) {
z-index: 2;
}
&:nth-of-type(2) {
z-index: 1;
}
&:last-child {
margin-right: 0;
}
}
.avatar {
height: $gl-padding;
width: $gl-padding;
margin-right: 0;
vertical-align: bottom;
}
.avatar-counter {
height: $gl-padding;
border: 1px solid transparent;
background-color: $gl-text-color-tertiary;
font-weight: $gl-font-weight-bold;
padding: 0 $gl-padding-4;
line-height: $gl-padding;
}
}
}
.btn-item-remove {
position: absolute;
right: 0;
top: $gl-padding-4 / 2;
padding: $gl-padding-4;
margin-right: $gl-padding-4 / 2;
line-height: 0;
border-color: transparent;
color: $gl-text-color-secondary;
&:hover {
color: $gl-text-color;
}
}
}
@include media-breakpoint-up(sm) {
.item-body {
.item-contents .item-title .sortable-link {
max-width: 90%;
}
}
}
/* Small devices (landscape phones, 768px and up) */
@include media-breakpoint-up(md) {
.item-body {
.item-contents {
min-width: 0;
.item-title {
flex-basis: unset;
// 98% because we compensate
// for remove button which is
// positioned absolutely
width: 95%;
margin-bottom: $gl-padding-4;
.sortable-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
}
}
.item-meta {
.item-path-id {
order: 0;
margin-top: 0;
}
.item-meta-child {
flex-basis: unset;
margin-left: auto;
margin-right: $gl-padding-4;
~ .item-assignees {
margin-left: $gl-padding-4;
}
}
.item-assignees {
margin-bottom: 0;
margin-left: 0;
order: 2;
}
}
}
.btn-item-remove {
order: 1;
}
}
}
/* Medium devices (desktops, 992px and up) */
@include media-breakpoint-up(lg) {
.item-body {
padding: $gl-padding;
.item-title {
font-size: $gl-font-size;
}
.item-meta .item-path-id {
font-size: inherit; // Base size given to `item-meta` is `$gl-font-size-small`
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
margin-right: $gl-padding-4;
}
}
}
/* Large devices (large desktops, 1200px and up) */
@include media-breakpoint-up(xl) {
.item-body {
padding: $gl-padding-8;
padding-left: $gl-padding;
.item-contents {
flex-wrap: nowrap;
overflow: hidden;
.item-title {
display: flex;
margin-bottom: 0;
min-width: 0;
width: auto;
flex-basis: unset;
font-weight: $gl-font-weight-normal;
.sortable-link {
display: block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: block;
margin-right: $gl-padding-8;
}
.confidential-icon {
align-self: auto;
margin-top: 0;
}
}
.item-meta {
margin-top: 0;
justify-content: flex-end;
flex: 1;
flex-wrap: nowrap;
.item-path-id {
order: 0;
margin-top: 0;
margin-left: $gl-padding-8;
margin-right: auto;
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: none;
}
}
.item-meta-child {
margin-left: $gl-padding-8;
flex-wrap: nowrap;
}
.item-assignees {
flex-grow: 0;
margin-top: 0;
margin-right: $gl-padding-4;
.avatar {
height: $gl-padding-24;
width: $gl-padding-24;
}
.avatar-counter {
height: $gl-padding-24;
line-height: $gl-padding-24;
border-radius: $gl-padding-24;
}
}
}
}
.btn-item-remove {
position: relative;
align-self: center;
top: initial;
right: 0;
margin-right: 0;
padding: $btn-sm-side-margin;
&:hover {
border-color: $border-color;
}
}
}
}
......@@ -33,7 +33,6 @@ $token-spacing-bottom: 0.5em;
li .issuable-info-container {
padding-left: $gl-padding;
padding-right: $gl-padding-4;
@include media-breakpoint-down(sm) {
padding-left: $gl-padding-8;
......
......@@ -15,6 +15,8 @@ class Groups::SsoController < Groups::ApplicationController
def saml
@group_path = params[:group_id]
@group_name = @unauthenticated_group.full_name
@group_saml_identity = linked_identity
@idp_url = @unauthenticated_group.saml_provider.sso_url
end
def unlink
......
......@@ -219,9 +219,10 @@ module EE
def update_project_counter_caches
end
def issues_readable_by(current_user)
def issues_readable_by(current_user, preload: nil)
related_issues = ::Issue.select('issues.*, epic_issues.id as epic_issue_id, epic_issues.relative_position')
.joins(:epic_issue)
.preload(preload)
.where("epic_issues.epic_id = #{id}")
.order('epic_issues.relative_position, epic_issues.id')
......
......@@ -5,7 +5,7 @@ class SamlProvider < ActiveRecord::Base
has_many :identities
validates :group, presence: true, top_level_group: true
validates :sso_url, presence: true, url: { protocols: %w(https) }
validates :sso_url, presence: true, url: { protocols: %w(https), ascii_only: true }
validates :certificate_fingerprint, presence: true, certificate_fingerprint: true
after_initialize :set_defaults, if: :new_record?
......
......@@ -7,7 +7,7 @@ module EpicIssues
def child_issuables
return [] unless issuable&.group&.feature_available?(:epics)
issuable.issues_readable_by(current_user)
issuable.issues_readable_by(current_user, preload: preload_for_collection)
end
def relation_path(issue)
......
......@@ -18,6 +18,10 @@ module IssuableLinks
private
def preload_for_collection
[{ project: :namespace }, :assignees]
end
def relation_path(object)
raise NotImplementedError
end
......@@ -30,15 +34,24 @@ module IssuableLinks
project_issue_path(object.project, object.iid)
end
# rubocop: disable CodeReuse/Serializer
def to_hash(object)
{
id: object.id,
confidential: object.confidential,
title: object.title,
assignees: UserSerializer.new.represent(object.assignees),
state: object.state,
milestone: MilestoneSerializer.new.represent(object.milestone),
weight: object.weight,
reference: reference(object),
path: issuable_path(object),
relation_path: relation_path(object)
relation_path: relation_path(object),
due_date: object.due_date,
created_at: object.created_at&.to_s,
closed_at: object.closed_at
}
end
# rubocop: enable CodeReuse/Serializer
end
end
......@@ -7,7 +7,7 @@ module IssueLinks
private
def child_issuables
issuable.related_issues(current_user, preload: { project: :namespace })
issuable.related_issues(current_user, preload: preload_for_collection)
end
def relation_path(issue)
......
......@@ -3,8 +3,19 @@
= render 'devise/shared/tab_single', tab_title: _('SAML SSO')
.login-box
.login-body
%h4= _("Sign in to %{group_name}") % { group_name: @group_name }
- if @group_saml_identity
%h4= _('Sign in to "%{group_name}"') % { group_name: @group_name }
- else
%h4= _('Allow "%{group_name}" to sign you in') % { group_name: @group_name }
%p= _("This group allows you to sign in with your %{group_name} Single Sign-On account. This will redirect you to an external sign in page.") % { group_name: @group_name }
%p= _('The "%{group_path}" group allows you to sign in with your Single Sign-On Account') % { group_path: @group_path }
= saml_link _('Sign in with Single Sign-On'), @group_path, html_class: 'btn btn-success btn-block qa-saml-sso-signin-button'
- if @group_saml_identity
%p= _("This will redirect you to an external sign in page.")
= saml_link _('Sign in with Single Sign-On'), @group_path, html_class: 'btn btn-success btn-block qa-saml-sso-signin-button'
- else
.card.card-body.bs-callout-warning
= _("Only proceed if you trust %{idp_url} to control your GitLab account sign in.") % { idp_url: @idp_url }
= saml_link _('Authorize'), @group_path, html_class: 'btn btn-success btn-block qa-saml-sso-signin-button'
......@@ -7,7 +7,7 @@
.billing-plans-alert.card.prepend-top-10
.card-header.bg-warning.text-white
= s_("BillingPlans|Automatic downgrade and upgrade to some plans is currently not available.")
- customer_support_url = 'https://support.gitlab.com/hc/en-us/requests/new?ticket_form_id=334447';
- customer_support_url = 'https://about.gitlab.com/sales/';
- customer_support_link = link_to s_("BillingPlans|Customer Support"), customer_support_url
= s_("BillingPlans|Please contact %{customer_support_link} in that case.").html_safe % { customer_support_link: customer_support_link }
......
---
title: Added recommendations for handling deleted documents in Elasticsearch
merge_request:
author:
type: other
---
title: Epic issue list and related issue list re-design
merge_request:
author:
type: changed
---
title: Group SAML SSO page warns when linking account
merge_request: 8295
author:
type: changed
......@@ -3,9 +3,10 @@ require 'spec_helper'
describe Groups::EpicIssuesController do
let(:group) { create(:group, :public) }
let(:project) { create(:project, :public, group: group) }
let(:milestone) { create(:milestone, project: project) }
let(:epic) { create(:epic, group: group) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
let(:user) { create(:user) }
let(:issue) { create(:issue, project: project, milestone: milestone, assignees: [user]) }
before do
stub_licensed_features(epics: true)
......@@ -45,18 +46,7 @@ describe Groups::EpicIssuesController do
end
it 'returns the correct json' do
expected_result = [
{
'id' => issue.id,
'title' => issue.title,
'state' => issue.state,
'reference' => "#{project.full_path}##{issue.iid}",
'path' => "/#{project.full_path}/issues/#{issue.iid}",
'relation_path' => "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue.id}",
'epic_issue_id' => epic_issue.id
}
]
expect(JSON.parse(response.body)).to eq(expected_result)
expect(JSON.parse(response.body)).to match_schema('related_issues', dir: 'ee')
end
end
end
......
......@@ -32,7 +32,7 @@ describe 'Epic Issues', :js do
end
it 'user can see issues from public project but cannot delete the associations' do
within('.related-issues-block ul.issuable-list') do
within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1)
expect(page).to have_content(public_issue.title)
expect(page).not_to have_selector('button.js-issue-item-remove-button')
......@@ -70,7 +70,7 @@ describe 'Epic Issues', :js do
end
it 'user can see all issues of the group and delete the associations' do
within('.related-issues-block ul.issuable-list') do
within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 2)
expect(page).to have_content(public_issue.title)
expect(page).to have_content(private_issue.title)
......@@ -80,7 +80,7 @@ describe 'Epic Issues', :js do
wait_for_requests
within('.related-issues-block ul.issuable-list') do
within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1)
end
end
......@@ -100,7 +100,7 @@ describe 'Epic Issues', :js do
expect(page).not_to have_selector('.content-wrapper .alert-wrapper .flash-text')
expect(page).not_to have_content('No Issue found for given params')
within('.related-issues-block ul.issuable-list') do
within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 3)
expect(page).to have_content(issue_to_add.title)
end
......@@ -110,7 +110,7 @@ describe 'Epic Issues', :js do
expect(first('.js-related-issues-token-list-item')).to have_content(public_issue.title)
expect(page.all('.js-related-issues-token-list-item').last).to have_content(private_issue.title)
drag_to(selector: '.issuable-list', to_index: 1)
drag_to(selector: '.related-items-list', to_index: 1)
expect(first('.js-related-issues-token-list-item')).to have_content(private_issue.title)
expect(page.all('.js-related-issues-token-list-item').last).to have_content(public_issue.title)
......
......@@ -154,15 +154,37 @@ describe 'SAML provider settings' do
context 'when signed in' do
before do
sign_in(user)
end
it 'shows warning that linking accounts authorizes control over sign in' do
visit sso_group_saml_providers_path(group)
expect(page).to have_content(/Allow .* to sign you in/)
expect(page).to have_content(saml_provider.sso_url)
expect(page).to have_content('Authorize')
end
it 'Sign in button redirects to auth flow and back to group' do
click_link 'Sign in with Single Sign-On'
it 'Authorize/link button redirects to auth flow' do
visit sso_group_saml_providers_path(group)
click_link 'Authorize'
expect(current_path).to eq callback_path
end
context 'with linked account' do
before do
create(:group_saml_identity, saml_provider: saml_provider, user: user)
end
it 'Sign in button redirects to auth flow' do
visit sso_group_saml_providers_path(group)
click_link 'Sign in with Single Sign-On'
expect(current_path).to eq callback_path
end
end
end
context 'for a private group' do
......@@ -187,7 +209,7 @@ describe 'SAML provider settings' do
expect(current_path).to eq sso_group_saml_providers_path(group)
within '.login-box' do
expect(page).to have_link 'Sign in with Single Sign-On'
expect(page).to have_link 'Authorize'
end
end
......
......@@ -258,7 +258,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
# Form gets hidden after submission
expect(page).not_to have_selector('.js-add-related-issues-form-area')
......@@ -275,7 +275,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(1)
expect(items[0].text).to eq(issue_project_b_a.title)
......@@ -289,7 +289,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(1)
expect(items[0].text).to eq(issue_project_b_a.title)
......@@ -311,7 +311,7 @@ describe 'Related issues', :js do
end
it 'shows related issues' do
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title)
......@@ -319,7 +319,7 @@ describe 'Related issues', :js do
end
it 'allows us to remove a related issues' do
items_before = all('.js-related-issues-token-list-item .issue-token-title-text')
items_before = all('.item-title a')
expect(items_before.count).to eq(2)
......@@ -327,7 +327,7 @@ describe 'Related issues', :js do
wait_for_requests
items_after = all('.js-related-issues-token-list-item .issue-token-title-text')
items_after = all('.item-title a')
expect(items_after.count).to eq(1)
end
......@@ -339,7 +339,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(3)
expect(items[0].text).to eq(issue_b.title)
......@@ -355,7 +355,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title)
......@@ -370,7 +370,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title)
......
{
"type": "object",
"additionalProperties": false,
"required": [
"id",
"confidential",
"title",
"assignees",
"milestone",
"due_date",
"state",
"reference",
"path",
"relation_path",
"weight"
],
"properties": {
"id": { "type": "integer" },
"confidential": { "type": "boolean" },
"title": { "type": "string" },
"assignees": { "type": "array" },
"milestone": { "type": ["object", "null"] },
"due_date": { "type": ["string", "null"] },
"state": { "type": "string" },
"weight": { "type": ["integer", "null"] },
"reference": { "type": "string" },
"path": { "type": "string" },
"relation_path": { "type": "string" },
"epic_issue_id": { "type": ["integer", "null"] },
"created_at": { "type": "string" },
"closed_at": { "type": ["string", "null"] }
}
}
{
"type": "array",
"items": { "$ref": "related_issue.json" }
}
......@@ -48,6 +48,6 @@ describe('Issue card component', () => {
const el = vm.$el.querySelector('.board-card-weight');
expect(el).not.toBeNull();
expect(el.textContent.trim()).toBe('2');
expect(el.textContent.trim()).toContain('2');
});
});
......@@ -55,7 +55,7 @@ describe('EpicBodyComponent', () => {
expect(vm.$el.querySelector('.related-issues-block')).not.toBeNull();
expect(vm.$el.querySelector('.js-related-issues-header-issue-count')).not.toBeNull();
expect(vm.$el.querySelector('.related-issues-token-body')).not.toBeNull();
expect(vm.$el.querySelector('.issuable-list')).not.toBeNull();
expect(vm.$el.querySelector('.related-items-list')).not.toBeNull();
});
});
});
......@@ -21,6 +21,8 @@ const issuable2 = {
state: 'opened',
};
const pathIdSeparator = '#';
describe('AddIssuableForm', () => {
let AddIssuableForm;
let vm;
......@@ -47,6 +49,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue: '',
pendingReferences: [],
pathIdSeparator,
},
}).$mount();
});
......@@ -63,6 +66,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue: 'foo',
pendingReferences: [],
pathIdSeparator,
},
}).$mount();
});
......@@ -81,6 +85,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue,
pendingReferences: [issuable1.reference, issuable2.reference],
pathIdSeparator,
},
}).$mount();
});
......@@ -105,6 +110,7 @@ describe('AddIssuableForm', () => {
inputValue: '',
pendingReferences: [issuable1.reference, issuable2.reference],
isSubmitting: true,
pathIdSeparator,
},
}).$mount();
});
......@@ -125,6 +131,7 @@ describe('AddIssuableForm', () => {
autoCompleteSources: {
issues: '/fake/issues/path',
},
pathIdSeparator,
},
}).$mount();
});
......@@ -144,6 +151,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue: '',
autoCompleteSources: {},
pathIdSeparator,
},
}).$mount();
});
......@@ -185,6 +193,7 @@ describe('AddIssuableForm', () => {
autoCompleteSources: {
issues: '/fake/issues/path',
},
pathIdSeparator,
},
}).$mount(el);
});
......
......@@ -2,14 +2,22 @@ import Vue from 'vue';
import issueItem from 'ee/related_issues/components/issue_item.vue';
import eventHub from 'ee/related_issues/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { defaultMilestone, defaultAssignees } from '../mock_data';
describe('issueItem', () => {
let vm;
const props = {
idKey: 1,
displayReference: '#1',
displayReference: 'gitlab-org/gitlab-test#1',
pathIdSeparator: '#',
path: `${gl.TEST_HOST}/path`,
title: 'title',
confidential: true,
dueDate: '2018-12-31',
weight: 10,
createdAt: '2018-12-01T00:00:00.00Z',
milestone: defaultMilestone,
assignees: defaultAssignees,
};
beforeEach(() => {
......@@ -22,12 +30,6 @@ describe('issueItem', () => {
expect(vm.$el.querySelector('.issuable-info-container')).toBeNull();
});
it('renders displayReference', () => {
expect(vm.$el.querySelector('.text-secondary').innerText.trim()).toEqual(
props.displayReference,
);
});
it('does not render token state', () => {
expect(vm.$el.querySelector('.text-secondary svg')).toBeNull();
});
......@@ -38,11 +40,17 @@ describe('issueItem', () => {
describe('token title', () => {
it('links to computedPath', () => {
expect(vm.$el.querySelector('a').href).toEqual(props.path);
expect(vm.$el.querySelector('.item-title a').href).toEqual(props.path);
});
it('renders confidential icon', () => {
expect(
vm.$el.querySelector('.item-title svg.confidential-icon use').getAttribute('xlink:href'),
).toContain('eye-slash');
});
it('renders title', () => {
expect(vm.$el.querySelector('a').innerText.trim()).toEqual(props.title);
expect(vm.$el.querySelector('.item-title a').innerText.trim()).toEqual(props.title);
});
});
......@@ -52,7 +60,7 @@ describe('issueItem', () => {
beforeEach(done => {
vm.state = 'opened';
Vue.nextTick(() => {
tokenState = vm.$el.querySelector('.text-secondary svg');
tokenState = vm.$el.querySelector('.item-meta svg');
done();
});
});
......@@ -62,7 +70,12 @@ describe('issueItem', () => {
});
it('renders state title', () => {
expect(tokenState.getAttribute('data-original-title')).toEqual('Open');
const stateTitle = tokenState.getAttribute('data-original-title').trim();
expect(stateTitle).toContain('<span class="bold">Opened</span>');
expect(stateTitle).toContain(
'<span class="text-tertiary">Dec 1, 2018 12:00am GMT+0000</span>',
);
});
it('renders aria label', () => {
......@@ -75,6 +88,7 @@ describe('issueItem', () => {
it('renders close icon when close state', done => {
vm.state = 'closed';
vm.closedAt = '2018-12-01T00:00:00.00Z';
Vue.nextTick(() => {
expect(tokenState.classList.contains('issue-token-state-icon-closed')).toEqual(true);
......@@ -83,6 +97,57 @@ describe('issueItem', () => {
});
});
describe('token metadata', () => {
let tokenMetadata;
beforeEach(done => {
Vue.nextTick(() => {
tokenMetadata = vm.$el.querySelector('.item-meta');
done();
});
});
it('renders item path and ID', () => {
const pathAndID = tokenMetadata.querySelector('.item-path-id').innerText.trim();
expect(pathAndID).toContain('gitlab-org/gitlab-test');
expect(pathAndID).toContain('#1');
});
it('renders milestone icon and name', () => {
const milestoneIconEl = tokenMetadata.querySelector('.item-milestone svg use');
const milestoneTitle = tokenMetadata.querySelector('.item-milestone .milestone-title');
expect(milestoneIconEl.getAttribute('xlink:href')).toContain('clock');
expect(milestoneTitle.innerText.trim()).toContain('Milestone title');
});
it('renders date icon and due date', () => {
const dueDateIconEl = tokenMetadata.querySelector('.item-due-date svg use');
const dueDateEl = tokenMetadata.querySelector('.item-due-date time');
expect(dueDateIconEl.getAttribute('xlink:href')).toContain('calendar');
expect(dueDateEl.innerText.trim()).toContain('Dec 31');
});
it('renders weight icon and value', () => {
const dueDateIconEl = tokenMetadata.querySelector('.item-weight svg use');
const dueDateEl = tokenMetadata.querySelector('.item-weight span');
expect(dueDateIconEl.getAttribute('xlink:href')).toContain('weight');
expect(dueDateEl.innerText.trim()).toContain('10');
});
});
describe('token assignees', () => {
it('renders assignees avatars', () => {
const assigneesEl = vm.$el.querySelector('.item-assignees');
expect(assigneesEl.querySelectorAll('.user-avatar-link').length).toBe(2);
expect(assigneesEl.querySelector('.avatar-counter').innerText.trim()).toContain('+2');
});
});
describe('remove button', () => {
let removeBtn;
......
......@@ -6,6 +6,7 @@ describe('IssueToken', () => {
const idKey = 200;
const displayReference = 'foo/bar#123';
const title = 'some title';
const pathIdSeparator = '#';
let IssueToken;
let vm;
......@@ -25,6 +26,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
},
}).$mount();
});
......@@ -45,6 +47,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
title,
},
}).$mount();
......@@ -63,6 +66,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
title,
path,
},
......@@ -81,6 +85,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
state: 'opened',
},
}).$mount();
......@@ -97,6 +102,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
state: 'reopened',
},
}).$mount();
......@@ -113,6 +119,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
state: 'closed',
},
}).$mount();
......@@ -131,6 +138,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
title,
state,
},
......@@ -153,6 +161,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
},
}).$mount();
});
......@@ -168,6 +177,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
canRemove: true,
},
}).$mount();
......@@ -187,6 +197,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
},
}).$mount();
removeRequestSpy = jasmine.createSpy('spy');
......
......@@ -7,6 +7,7 @@ export const defaultProps = {
export const issuable1 = {
id: 200,
epic_issue_id: 1,
confidential: false,
reference: 'foo/bar#123',
displayReference: '#123',
title: 'some title',
......@@ -17,6 +18,7 @@ export const issuable1 = {
export const issuable2 = {
id: 201,
epic_issue_id: 2,
confidential: false,
reference: 'foo/bar#124',
displayReference: '#124',
title: 'some other thing',
......@@ -27,6 +29,7 @@ export const issuable2 = {
export const issuable3 = {
id: 202,
epic_issue_id: 3,
confidential: false,
reference: 'foo/bar#125',
displayReference: '#125',
title: 'some other other thing',
......@@ -37,6 +40,7 @@ export const issuable3 = {
export const issuable4 = {
id: 203,
epic_issue_id: 4,
confidential: false,
reference: 'foo/bar#126',
displayReference: '#126',
title: 'some other other other thing',
......@@ -47,9 +51,61 @@ export const issuable4 = {
export const issuable5 = {
id: 204,
epic_issue_id: 5,
confidential: false,
reference: 'foo/bar#127',
displayReference: '#127',
title: 'some other other other thing',
path: '/foo/bar/issues/127',
state: 'opened',
};
export const defaultMilestone = {
id: 1,
state: 'active',
title: 'Milestone title',
start_date: '2018-01-01',
due_date: '2019-12-31',
};
export const defaultAssignees = [
{
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/root`,
status_tooltip_html: null,
path: '/root',
},
{
id: 13,
name: 'Brooks Beatty',
username: 'brynn_champlin',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/brynn_champlin`,
status_tooltip_html: null,
path: '/brynn_champlin',
},
{
id: 6,
name: 'Bryce Turcotte',
username: 'melynda',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/melynda`,
status_tooltip_html: null,
path: '/melynda',
},
{
id: 20,
name: 'Conchita Eichmann',
username: 'juliana_gulgowski',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/juliana_gulgowski`,
status_tooltip_html: null,
path: '/juliana_gulgowski',
},
];
......@@ -16,6 +16,15 @@ describe SamlProvider do
expect(subject).not_to allow_value('http://example.com').for(:sso_url)
end
it 'prevents homoglyph phishing attacks by only allowing ascii URLs' do
expect(subject).to allow_value('https://gitlab.com/adfs/ls').for(:sso_url)
expect(subject).not_to allow_value('https://𝕘itⅼaƄ.ᴄοm/adfs/ls').for(:sso_url)
end
it 'allows unicode domain names when encoded as ascii punycode' do
expect(subject).to allow_value('https://xn--gitl-ocb944a.xn--m-rmb025q/adfs/ls').for(:sso_url)
end
it 'expects certificate_fingerprint to be in an accepted format' do
expect(subject).to allow_value('000030EDC285E01D6B5EA33010A79ADD142F5004').for(:certificate_fingerprint)
expect(subject).to allow_value('00:00:30:ED:C2:85:E0:1D:6B:5E:A3:30:10:A7:9A:DD:14:2F:50:04').for(:certificate_fingerprint)
......
......@@ -7,7 +7,7 @@ describe EpicIssues::ListService do
let(:other_project) { create(:project_empty_repo, group: group) }
let(:epic) { create(:epic, group: group) }
let(:issue1) { create :issue, project: project }
let(:issue1) { create :issue, project: project, weight: 1 }
let(:issue2) { create :issue, project: project }
let(:issue3) { create :issue, project: other_project }
......@@ -31,6 +31,36 @@ describe EpicIssues::ListService do
stub_licensed_features(epics: true)
end
it 'does not have N+1 queries', :use_clean_rails_memory_store_caching, :request_store do
# The control query is made with the worst case scenario:
# * Two different issues from two different projects that belong to two different groups
# Then a new group with a new project is created and we do the call again to check if there will be no
# additional queries.
group.add_developer(user)
list_service = described_class.new(epic, user)
new_group = create(:group, :private)
new_group.add_developer(user)
new_project = create(:project, namespace: new_group)
milestone = create(:milestone, project: project)
milestone2 = create(:milestone, project: new_project)
new_issue1 = create(:issue, project: project, milestone: milestone, assignees: [user])
new_issue3 = create(:issue, project: new_project, milestone: milestone2)
create(:epic_issue, issue: new_issue1, epic: epic, relative_position: 3)
create(:epic_issue, issue: new_issue3, epic: epic, relative_position: 5)
control_count = ActiveRecord::QueryRecorder.new { list_service.execute }.count
new_group2 = create(:group, :private)
new_project2 = create(:project, namespace: new_group2)
new_group2.add_developer(user)
milestone3 = create(:milestone, project: new_project2)
new_issue4 = create(:issue, project: new_project, milestone: milestone3)
create(:epic_issue, issue: new_issue4, epic: epic, relative_position: 6)
expect { list_service.execute }.not_to exceed_query_limit(control_count)
end
context 'owner can see all issues and destroy their associations' do
before do
group.add_developer(user)
......@@ -41,31 +71,53 @@ describe EpicIssues::ListService do
{
id: issue2.id,
title: issue2.title,
assignees: [],
state: issue2.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue2.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue2.iid}",
relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue2.id}",
epic_issue_id: epic_issue2.id
epic_issue_id: epic_issue2.id,
due_date: nil,
created_at: issue2.created_at.to_s,
closed_at: issue2.closed_at
},
{
id: issue1.id,
title: issue1.title,
assignees: [],
state: issue1.state,
milestone: nil,
weight: 1,
confidential: false,
reference: issue1.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue1.iid}",
relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue1.id}",
epic_issue_id: epic_issue1.id
epic_issue_id: epic_issue1.id,
due_date: nil,
created_at: issue1.created_at.to_s,
closed_at: issue1.closed_at
},
{
id: issue3.id,
title: issue3.title,
assignees: [],
state: issue3.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue3.to_reference(full: true),
path: "/#{other_project.full_path}/issues/#{issue3.iid}",
relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue3.id}",
epic_issue_id: epic_issue3.id
epic_issue_id: epic_issue3.id,
due_date: nil,
created_at: issue3.created_at.to_s,
closed_at: issue3.closed_at
}
]
expect(subject).to eq(expected_result)
end
end
......@@ -80,20 +132,34 @@ describe EpicIssues::ListService do
{
id: issue2.id,
title: issue2.title,
assignees: [],
state: issue2.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue2.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue2.iid}",
relation_path: nil,
epic_issue_id: epic_issue2.id
epic_issue_id: epic_issue2.id,
due_date: nil,
created_at: issue2.created_at.to_s,
closed_at: issue2.closed_at
},
{
id: issue1.id,
title: issue1.title,
assignees: [],
state: issue1.state,
milestone: nil,
weight: 1,
confidential: false,
reference: issue1.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue1.iid}",
relation_path: nil,
epic_issue_id: epic_issue1.id
epic_issue_id: epic_issue1.id,
due_date: nil,
created_at: issue1.created_at.to_s,
closed_at: issue1.closed_at
}
]
......
......@@ -88,7 +88,7 @@ describe Groups::AutocompleteService do
let!(:subgroup_milestone) { create(:milestone, group: sub_group) }
before do
sub_group.add_guest(user)
sub_group.add_master(user)
end
context 'when group is public' do
......
......@@ -39,8 +39,9 @@ describe IssueLinks::ListService do
control_count = ActiveRecord::QueryRecorder.new { subject }.count
project = create :project, :public
issue_x = create :issue, project: project
issue_y = create :issue, project: project
milestone = create :milestone, project: project
issue_x = create :issue, project: project, milestone: milestone
issue_y = create :issue, project: project, assignees: [user]
issue_z = create :issue, project: project
create :issue_link, source: issue_x, target: issue_y
create :issue_link, source: issue_x, target: issue_z
......
......@@ -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)
......
......@@ -8,7 +8,7 @@ module Gitlab
BlockedUrlError = Class.new(StandardError)
class << self
def validate!(url, allow_localhost: false, allow_local_network: true, enforce_user: false, ports: [], protocols: [])
def validate!(url, ports: [], protocols: [], allow_localhost: false, allow_local_network: true, ascii_only: false, enforce_user: false)
return true if url.nil?
# Param url can be a string, URI or Addressable::URI
......@@ -22,6 +22,7 @@ module Gitlab
validate_port!(port, ports) if ports.any?
validate_user!(uri.user) if enforce_user
validate_hostname!(uri.hostname)
validate_unicode_restriction!(uri) if ascii_only
begin
addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM).map do |addr|
......@@ -91,6 +92,12 @@ module Gitlab
raise BlockedUrlError, "Hostname or IP address invalid"
end
def validate_unicode_restriction!(uri)
return if uri.to_s.ascii_only?
raise BlockedUrlError, "URI must be ascii only #{uri.to_s.dump}"
end
def validate_localhost!(addrs_info)
local_ips = ["::", "0.0.0.0"]
local_ips.concat(Socket.ip_address_list.map(&:ip_address))
......
......@@ -116,6 +116,9 @@ msgstr ""
msgid "%{authorsName}'s discussion"
msgstr ""
msgid "%{bio} at %{organization}"
msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
......@@ -578,6 +581,9 @@ msgstr ""
msgid "All users"
msgstr ""
msgid "Allow \"%{group_name}\" to sign you in"
msgstr ""
msgid "Allow commits from members who can merge to the target branch."
msgstr ""
......@@ -1055,6 +1061,9 @@ msgstr ""
msgid "Available specific runners"
msgstr ""
msgid "Avatar for %{assigneeName}"
msgstr ""
msgid "Avatar will be removed. Are you sure?"
msgstr ""
......@@ -3456,6 +3465,9 @@ msgstr ""
msgid "Expiration date"
msgstr ""
msgid "Expired %{expiredOn}"
msgstr ""
msgid "Expires in %{expires_at}"
msgstr ""
......@@ -6012,6 +6024,9 @@ msgstr ""
msgid "Only mirror protected branches"
msgstr ""
msgid "Only proceed if you trust %{idp_url} to control your GitLab account sign in."
msgstr ""
msgid "Only project members can comment."
msgstr ""
......@@ -7884,7 +7899,7 @@ msgstr ""
msgid "Sign in / Register"
msgstr ""
msgid "Sign in to %{group_name}"
msgid "Sign in to \"%{group_name}\""
msgstr ""
msgid "Sign in via 2FA code"
......@@ -8196,9 +8211,15 @@ msgstr ""
msgid "Started"
msgstr ""
msgid "Started %{startsIn}"
msgstr ""
msgid "Starting..."
msgstr ""
msgid "Starts %{startsIn}"
msgstr ""
msgid "Starts at (UTC)"
msgstr ""
......@@ -8463,6 +8484,9 @@ msgstr ""
msgid "Thanks! Don't show me this again"
msgstr ""
msgid "The \"%{group_path}\" group allows you to sign in with your Single Sign-On Account"
msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
......@@ -8682,9 +8706,6 @@ msgstr ""
msgid "This group"
msgstr ""
msgid "This group allows you to sign in with your %{group_name} Single Sign-On account. This will redirect you to an external sign in page."
msgstr ""
msgid "This group does not provide any group Runners yet."
msgstr ""
......@@ -8820,6 +8841,9 @@ msgstr ""
msgid "This user will be the author of all events in the activity feed that are the result of an update, like new branches being created or new commits being pushed to existing branches. Upon creation or when reassigning you can only assign yourself to be the mirror user."
msgstr ""
msgid "This will redirect you to an external sign in page."
msgstr ""
msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
msgstr ""
......
......@@ -361,8 +361,14 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
end
end
it 'shows jump to next discussion button' do
expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn'))
it 'shows jump to next discussion button except on last discussion' do
wait_for_requests
all_discussion_replies = page.all('.discussion-reply-holder')
expect(all_discussion_replies.count).to eq(2)
expect(all_discussion_replies.first.all('.discussion-next-btn').count).to eq(1)
expect(all_discussion_replies.last.all('.discussion-next-btn').count).to eq(0)
end
it 'displays next discussion even if hidden' do
......@@ -380,7 +386,13 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
page.find('.discussion-next-btn').click
end
expect(find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
page.all('.note-discussion').first do
expect(page.find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
end
page.all('.note-discussion').last do
expect(page.find('.discussion-with-resolve-btn')).not.to have_selector('.btn', text: 'Resolve discussion')
end
end
end
......
......@@ -11,6 +11,7 @@ describe "User browses files" do
let(:user) { project.owner }
before do
stub_feature_flags(csslab: false)
sign_in(user)
end
......
......@@ -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';
......
......@@ -130,3 +130,12 @@ export const mockAssigneesList = [
path: '/root',
},
];
export const mockMilestone = {
id: 1,
state: 'active',
title: 'Milestone title',
description: 'Harum corporis aut consequatur quae dolorem error sequi quia.',
start_date: '2018-01-01',
due_date: '2019-12-31',
};
......@@ -26,7 +26,9 @@ import actions, {
toggleTreeOpen,
scrollToFile,
toggleShowTreeList,
renderFileForDiscussionId,
} from '~/diffs/store/actions';
import eventHub from '~/notes/event_hub';
import * as types from '~/diffs/store/mutation_types';
import axios from '~/lib/utils/axios_utils';
import mockDiffFile from 'spec/diffs/mock_data/diff_file';
......@@ -735,4 +737,63 @@ describe('DiffsStoreActions', () => {
expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true);
});
});
describe('renderFileForDiscussionId', () => {
const rootState = {
notes: {
discussions: [
{
id: '123',
diff_file: {
file_hash: 'HASH',
},
},
{
id: '456',
diff_file: {
file_hash: 'HASH',
},
},
],
},
};
let commit;
let $emit;
let scrollToElement;
const state = ({ collapsed, renderIt }) => ({
diffFiles: [
{
file_hash: 'HASH',
collapsed,
renderIt,
},
],
});
beforeEach(() => {
commit = jasmine.createSpy('commit');
scrollToElement = spyOnDependency(actions, 'scrollToElement').and.stub();
$emit = spyOn(eventHub, '$emit');
});
it('renders and expands file for the given discussion id', () => {
const localState = state({ collapsed: true, renderIt: false });
renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
expect(commit).toHaveBeenCalledWith('RENDER_FILE', localState.diffFiles[0]);
expect($emit).toHaveBeenCalledTimes(1);
expect(scrollToElement).toHaveBeenCalledTimes(1);
});
it('jumps to discussion on already rendered and expanded file', () => {
const localState = state({ collapsed: false, renderIt: true });
renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
expect(commit).not.toHaveBeenCalled();
expect($emit).toHaveBeenCalledTimes(1);
expect(scrollToElement).not.toHaveBeenCalled();
});
});
});
......@@ -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', () => {
......
......@@ -83,6 +83,7 @@ describe('noteable_discussion component', () => {
it('expands next unresolved discussion', done => {
const discussion2 = getJSONFixture(discussionWithTwoUnresolvedNotes)[0];
discussion2.resolved = false;
discussion2.active = true;
discussion2.id = 'next'; // prepare this for being identified as next one (to be jumped to)
vm.$store.dispatch('setInitialNotes', [discussionMock, discussion2]);
window.mrTabs.currentAction = 'show';
......
......@@ -305,6 +305,7 @@ export const discussionMock = {
],
individual_note: false,
resolvable: true,
active: true,
};
export const loggedOutnoteableData = {
......@@ -1173,6 +1174,7 @@ export const discussion1 = {
id: 'abc1',
resolvable: true,
resolved: false,
active: true,
diff_file: {
file_path: 'about.md',
},
......@@ -1209,6 +1211,7 @@ export const discussion2 = {
id: 'abc2',
resolvable: true,
resolved: false,
active: true,
diff_file: {
file_path: 'README.md',
},
......@@ -1226,6 +1229,7 @@ export const discussion2 = {
export const discussion3 = {
id: 'abc3',
resolvable: true,
active: true,
resolved: false,
diff_file: {
file_path: 'README.md',
......
......@@ -124,7 +124,7 @@ describe('Actions Notes Store', () => {
{ discussionId: discussionMock.id },
{ notes: [discussionMock] },
[{ type: 'EXPAND_DISCUSSION', payload: { discussionId: discussionMock.id } }],
[],
[{ type: 'diffs/renderFileForDiscussionId', payload: discussionMock.id }],
done,
);
});
......
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();
});
});
});
import Vue from 'vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockAssigneesList } from 'spec/boards/mock_data';
const createComponent = (assignees = mockAssigneesList, cssClass = '') => {
const Component = Vue.extend(IssueAssignees);
return mountComponent(Component, {
assignees,
cssClass,
});
};
describe('IssueAssigneesComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.maxVisibleAssignees).toBe(2);
expect(vm.maxAssigneeAvatars).toBe(3);
expect(vm.maxAssignees).toBe(99);
});
});
describe('computed', () => {
describe('countOverLimit', () => {
it('should return difference between assignees count and maxVisibleAssignees', () => {
expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees);
});
});
describe('assigneesToShow', () => {
it('should return assignees containing only 2 items when count more than maxAssigneeAvatars', () => {
expect(vm.assigneesToShow.length).toBe(2);
});
it('should return all assignees as it is when count less than maxAssigneeAvatars', () => {
vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
expect(vm.assigneesToShow.length).toBe(3);
});
});
describe('assigneesCounterTooltip', () => {
it('should return string containing count of remaining assignees when count more than maxAssigneeAvatars', () => {
expect(vm.assigneesCounterTooltip).toBe('3 more assignees');
});
});
describe('shouldRenderAssigneesCounter', () => {
it('should return `false` when assignees count less than maxAssigneeAvatars', () => {
vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
expect(vm.shouldRenderAssigneesCounter).toBe(false);
});
it('should return `true` when assignees count more than maxAssigneeAvatars', () => {
expect(vm.shouldRenderAssigneesCounter).toBe(true);
});
});
describe('assigneeCounterLabel', () => {
it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => {
expect(vm.assigneeCounterLabel).toBe('+3');
});
});
});
describe('methods', () => {
describe('avatarUrlTitle', () => {
it('returns string containing alt text for assignee avatar', () => {
expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
});
});
});
describe('template', () => {
it('renders component root element with class `issue-assignees`', () => {
expect(vm.$el.classList.contains('issue-assignees')).toBe(true);
});
it('renders assignee avatars', () => {
expect(vm.$el.querySelectorAll('.user-avatar-link').length).toBe(2);
});
it('renders assignee tooltips', () => {
const tooltipText = vm.$el
.querySelectorAll('.user-avatar-link')[0]
.querySelector('.js-assignee-tooltip').innerText;
expect(tooltipText).toContain('Assignee');
expect(tooltipText).toContain('Terrell Graham');
expect(tooltipText).toContain('@monserrate.gleichner');
});
it('renders additional assignees count', () => {
const avatarCounterEl = vm.$el.querySelector('.avatar-counter');
expect(avatarCounterEl.innerText.trim()).toBe('+3');
expect(avatarCounterEl.getAttribute('data-original-title')).toBe('3 more assignees');
});
});
});
import Vue from 'vue';
import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockMilestone } from 'spec/boards/mock_data';
const createComponent = (milestone = mockMilestone) => {
const Component = Vue.extend(IssueMilestone);
return mountComponent(Component, {
milestone,
});
};
describe('IssueMilestoneComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('isMilestoneStarted', () => {
it('should return `false` when milestoneStart prop is not defined', done => {
const vmStartUndefined = createComponent(
Object.assign({}, mockMilestone, {
start_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmStartUndefined.isMilestoneStarted).toBe(false);
})
.then(done)
.catch(done.fail);
vmStartUndefined.$destroy();
});
it('should return `true` when milestone start date is past current date', done => {
const vmStarted = createComponent(
Object.assign({}, mockMilestone, {
start_date: '1990-07-22',
}),
);
Vue.nextTick()
.then(() => {
expect(vmStarted.isMilestoneStarted).toBe(true);
})
.then(done)
.catch(done.fail);
vmStarted.$destroy();
});
});
describe('isMilestonePastDue', () => {
it('should return `false` when milestoneDue prop is not defined', done => {
const vmDueUndefined = createComponent(
Object.assign({}, mockMilestone, {
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmDueUndefined.isMilestonePastDue).toBe(false);
})
.then(done)
.catch(done.fail);
vmDueUndefined.$destroy();
});
it('should return `true` when milestone due is past current date', done => {
const vmPastDue = createComponent(
Object.assign({}, mockMilestone, {
due_date: '1990-07-22',
}),
);
Vue.nextTick()
.then(() => {
expect(vmPastDue.isMilestonePastDue).toBe(true);
})
.then(done)
.catch(done.fail);
vmPastDue.$destroy();
});
});
describe('milestoneDatesAbsolute', () => {
it('returns string containing absolute milestone due date', () => {
expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)');
});
it('returns string containing absolute milestone start date when due date is not present', done => {
const vmDueUndefined = createComponent(
Object.assign({}, mockMilestone, {
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmDueUndefined.milestoneDatesAbsolute).toBe('(January 1, 2018)');
})
.then(done)
.catch(done.fail);
vmDueUndefined.$destroy();
});
it('returns empty string when both milestone start and due dates are not present', done => {
const vmDatesUndefined = createComponent(
Object.assign({}, mockMilestone, {
start_date: '',
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmDatesUndefined.milestoneDatesAbsolute).toBe('');
})
.then(done)
.catch(done.fail);
vmDatesUndefined.$destroy();
});
});
describe('milestoneDatesHuman', () => {
it('returns string containing milestone due date when date is yet to be due', done => {
const vmFuture = createComponent(
Object.assign({}, mockMilestone, {
due_date: `${new Date().getFullYear() + 10}-01-01`,
}),
);
Vue.nextTick()
.then(() => {
expect(vmFuture.milestoneDatesHuman).toContain('years remaining');
})
.then(done)
.catch(done.fail);
vmFuture.$destroy();
});
it('returns string containing milestone start date when date has already started and due date is not present', done => {
const vmStarted = createComponent(
Object.assign({}, mockMilestone, {
start_date: '1990-07-22',
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmStarted.milestoneDatesHuman).toContain('Started');
})
.then(done)
.catch(done.fail);
vmStarted.$destroy();
});
it('returns string containing milestone start date when date is yet to start and due date is not present', done => {
const vmStarts = createComponent(
Object.assign({}, mockMilestone, {
start_date: `${new Date().getFullYear() + 10}-01-01`,
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmStarts.milestoneDatesHuman).toContain('Starts');
})
.then(done)
.catch(done.fail);
vmStarts.$destroy();
});
it('returns empty string when milestone start and due dates are not present', done => {
const vmDatesUndefined = createComponent(
Object.assign({}, mockMilestone, {
start_date: '',
due_date: '',
}),
);
Vue.nextTick()
.then(() => {
expect(vmDatesUndefined.milestoneDatesHuman).toBe('');
})
.then(done)
.catch(done.fail);
vmDatesUndefined.$destroy();
});
});
});
describe('template', () => {
it('renders component root element with class `issue-milestone-details`', () => {
expect(vm.$el.classList.contains('issue-milestone-details')).toBe(true);
});
it('renders milestone icon', () => {
expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('clock');
});
it('renders milestone title', () => {
expect(vm.$el.querySelector('.milestone-title').innerText.trim()).toBe(mockMilestone.title);
});
it('renders milestone tooltip', () => {
expect(vm.$el.querySelector('.js-item-milestone').innerText.trim()).toContain(
mockMilestone.title,
);
});
});
});
......@@ -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
......
......@@ -249,6 +249,27 @@ describe Gitlab::UrlBlocker do
end
end
end
context 'when ascii_only is true' do
it 'returns true for unicode domain' do
expect(described_class.blocked_url?('https://𝕘itⅼαƄ.com/foo/foo.bar', ascii_only: true)).to be true
end
it 'returns true for unicode tld' do
expect(described_class.blocked_url?('https://gitlab.ᴄοm/foo/foo.bar', ascii_only: true)).to be true
end
it 'returns true for unicode path' do
expect(described_class.blocked_url?('https://gitlab.com/𝒇οο/𝒇οο.Ƅαꮁ', ascii_only: true)).to be true
end
it 'returns true for IDNA deviations' do
expect(described_class.blocked_url?('https://mißile.com/foo/foo.bar', ascii_only: true)).to be true
expect(described_class.blocked_url?('https://miςςile.com/foo/foo.bar', ascii_only: true)).to be true
expect(described_class.blocked_url?('https://git‍lab.com/foo/foo.bar', ascii_only: true)).to be true
expect(described_class.blocked_url?('https://git‌lab.com/foo/foo.bar', ascii_only: true)).to be true
end
end
end
describe '#validate_hostname!' do
......
......@@ -143,4 +143,33 @@ describe UrlValidator do
end
end
end
context 'when ascii_only is' do
let(:url) { 'https://𝕘itⅼαƄ.com/foo/foo.bar'}
let(:validator) { described_class.new(attributes: [:link_url], ascii_only: ascii_only) }
context 'true' do
let(:ascii_only) { true }
it 'prevents unicode characters' do
badge.link_url = url
subject
expect(badge.errors.empty?).to be false
end
end
context 'false (default)' do
let(:ascii_only) { false }
it 'does not prevent unicode characters' do
badge.link_url = url
subject
expect(badge.errors.empty?).to be true
end
end
end
end
......@@ -616,6 +616,13 @@
lodash "^4.17.10"
to-fast-properties "^2.0.0"
"@gitlab/csslab@^1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@gitlab/csslab/-/csslab-1.8.0.tgz#54a2457fdc80f006665f0e578a5532780954ccfa"
integrity sha512-RZylRElufH1kwsBQlIDaVcrcXMyD5IEGrU6ABUd8W3LG8/F9jJ4Y3Ys7EPTpK/qFJyx86AutTtFGRxRNlMx85w==
dependencies:
bootstrap "4.1.3"
"@gitlab/eslint-config@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@gitlab/eslint-config/-/eslint-config-1.2.0.tgz#115568a70edabbc024f1bc13ba1ba499a9ba05a9"
......@@ -634,10 +641,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.41.0.tgz#f80e3a0e259f3550af00685556ea925e471276d3"
integrity sha512-tKUXyqe54efWBsjQBUcvNF0AvqmE2NI2No3Bnix/gKDRImzIlcgIkM67Y8zoJv1D0w4CO87WcaG5GLpIFIT1Pg==
"@gitlab/ui@^1.14.0":
version "1.14.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.14.0.tgz#f0fd7c0e6c45a36ab3be18d00e2908a8cb405f90"
integrity sha512-jkBTN8qO41A894kcLo6b/mfLIgL8YNn+ZzjgzEXaZ3PyeQ3mKBdrBoSYkzH556qviroBvk/+3yyZz96VUo08qQ==
"@gitlab/ui@^1.15.0":
version "1.15.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.15.0.tgz#288e189cb99de354aeb4598f9ac8cced5f47e139"
integrity sha512-Aiv/WABr8lBVJk0eoanSoO07Lr5Nnvuq82IjDnNzcw9enB1DAKvlstC2r9iiMfg1pVgV/uLdDeRFqH9eI1X4Rg==
dependencies:
babel-standalone "^6.26.0"
bootstrap-vue "^2.0.0-rc.11"
......
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