Commit 87ef501e authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent f321e51f
......@@ -15,7 +15,6 @@ import {
GlDropdownDivider,
} from '@gitlab/ui';
import { __, sprintf, n__ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import Stacktrace from './stacktrace.vue';
......@@ -28,7 +27,6 @@ import query from '../queries/details.query.graphql';
export default {
components: {
LoadingButton,
GlButton,
GlFormInput,
GlLink,
......@@ -234,19 +232,21 @@ export default {
</div>
<div class="error-details-actions">
<div class="d-inline-flex bv-d-sm-down-none">
<loading-button
:label="ignoreBtnLabel"
<gl-button
:loading="updatingIgnoreStatus"
data-qa-selector="update_ignore_status_button"
@click="onIgnoreStatusUpdate"
/>
<loading-button
>
{{ ignoreBtnLabel }}
</gl-button>
<gl-button
class="btn-outline-info ml-2"
:label="resolveBtnLabel"
:loading="updatingResolveStatus"
data-qa-selector="update_resolve_status_button"
@click="onResolveStatusUpdate"
/>
>
{{ resolveBtnLabel }}
</gl-button>
<gl-button
v-if="error.gitlabIssuePath"
class="ml-2"
......@@ -270,14 +270,15 @@ export default {
name="issue[sentry_issue_attributes][sentry_issue_identifier]"
/>
<gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" />
<loading-button
<gl-button
v-if="!error.gitlabIssuePath"
class="btn-success"
:label="__('Create issue')"
:loading="issueCreationInProgress"
data-qa-selector="create_issue_button"
@click="createIssue"
/>
>
{{ __('Create issue') }}
</gl-button>
</form>
</div>
<gl-dropdown
......
......@@ -236,6 +236,7 @@ export default {
</gl-dropdown>
<div class="filtered-search-input-container flex-fill">
<gl-form-input
v-model="errorSearchQuery"
class="pl-2 filtered-search"
:disabled="loading"
:placeholder="__('Search or filter results…')"
......
import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
import { s__ } from '~/locale';
const yAxisBoundaryGap = [0.1, 0.1];
/**
* Max string length of formatted axis tick
*/
const maxDataAxisTickLength = 8;
// Defaults
const defaultFormat = SUPPORTED_FORMATS.number;
const defaultYAxisFormat = defaultFormat;
const defaultYAxisPrecision = 2;
const defaultTooltipFormat = defaultFormat;
const defaultTooltipPrecision = 3;
// Give enough space for y-axis with units and name.
const chartGridLeft = 75;
// Axis options
/**
* Converts .yml parameters to echarts axis options for data axis
* @param {Object} param - Dashboard .yml definition options
*/
const getDataAxisOptions = ({ format, precision, name }) => {
const formatter = getFormatter(format);
return {
name,
nameLocation: 'center', // same as gitlab-ui's default
scale: true,
axisLabel: {
formatter: val => formatter(val, precision, maxDataAxisTickLength),
},
};
};
/**
* Converts .yml parameters to echarts y-axis options
* @param {Object} param - Dashboard .yml definition options
*/
export const getYAxisOptions = ({
name = s__('Metrics|Values'),
format = defaultYAxisFormat,
precision = defaultYAxisPrecision,
} = {}) => {
return {
nameGap: 63, // larger gap than gitlab-ui's default to fit with formatted numbers
scale: true,
boundaryGap: yAxisBoundaryGap,
...getDataAxisOptions({
name,
format,
precision,
}),
};
};
// Chart grid
/**
* Grid with enough room to display chart.
*/
export const getChartGrid = ({ left = chartGridLeft } = {}) => ({ left });
// Tooltip options
export const getTooltipFormatter = ({
format = defaultTooltipFormat,
precision = defaultTooltipPrecision,
} = {}) => {
const formatter = getFormatter(format);
return num => formatter(num, precision);
};
......@@ -4,7 +4,6 @@ import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { s__, __ } from '~/locale';
import { getFormatter } from '~/lib/utils/unit_format';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
import {
......@@ -16,6 +15,7 @@ import {
dateFormats,
chartColorValues,
} from '../../constants';
import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options';
import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils';
......@@ -30,15 +30,13 @@ const deploymentYAxisCoords = {
max: 100,
};
const THROTTLED_DATAZOOM_WAIT = 1000; // miliseconds
const THROTTLED_DATAZOOM_WAIT = 1000; // milliseconds
const timestampToISODate = timestamp => new Date(timestamp).toISOString();
const events = {
datazoom: 'datazoom',
};
const yValFormatter = getFormatter('number');
export default {
components: {
GlAreaChart,
......@@ -167,14 +165,7 @@ export default {
const option = omit(this.option, ['series', 'yAxis', 'xAxis']);
const dataYAxis = {
name: this.yAxisLabel,
nameGap: 50, // same as gitlab-ui's default
nameLocation: 'center', // same as gitlab-ui's default
boundaryGap: [0.1, 0.1],
scale: true,
axisLabel: {
formatter: num => yValFormatter(num, 3),
},
...getYAxisOptions(this.graphData.yAxis),
...yAxis,
};
......@@ -204,6 +195,7 @@ export default {
series: this.chartOptionSeries,
xAxis: timeXAxis,
yAxis: [dataYAxis, deploymentsYAxis],
grid: getChartGrid(),
dataZoom: [this.dataZoomConfig],
...option,
};
......@@ -282,8 +274,9 @@ export default {
},
};
},
yAxisLabel() {
return `${this.graphData.y_label}`;
tooltipYFormatter() {
// Use same format as y-axis
return getTooltipFormatter({ format: this.graphData.yAxis?.format });
},
},
created() {
......@@ -315,12 +308,11 @@ export default {
this.tooltip.commitUrl = deploy.commitUrl;
} else {
const { seriesName, color, dataIndex } = dataPoint;
const value = yValFormatter(yVal, 3);
this.tooltip.content.push({
name: seriesName,
dataIndex,
value,
value: this.tooltipYFormatter(yVal),
color,
});
}
......
......@@ -19,7 +19,7 @@ import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { mergeUrlParams, redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import Icon from '~/vue_shared/components/icon.vue';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
......@@ -351,6 +351,10 @@ export default {
};
redirectTo(mergeUrlParams(params, window.location.href));
},
refreshDashboard() {
refreshCurrentPage();
},
},
addMetric: {
title: s__('Metrics|Add metric'),
......@@ -438,7 +442,7 @@ export default {
:label="s__('Metrics|Show last')"
label-size="sm"
label-for="monitor-time-window-dropdown"
class="col-sm-6 col-md-6 col-lg-4"
class="col-sm-auto col-md-auto col-lg-auto"
>
<date-time-picker
ref="dateTimePicker"
......@@ -449,6 +453,18 @@ export default {
/>
</gl-form-group>
<gl-form-group class="col-sm-2 col-md-2 col-lg-1 refresh-dashboard-button">
<gl-button
ref="refreshDashboardBtn"
v-gl-tooltip
variant="default"
:title="s__('Metrics|Reload this page')"
@click="refreshDashboard"
>
<icon name="repeat" />
</gl-button>
</gl-form-group>
<gl-form-group
v-if="hasHeaderButtons"
label-for="prometheus-graphs-dropdown-buttons"
......
import { slugify } from '~/lib/utils/text_utility';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export const gqClient = createGqClient(
......@@ -74,18 +75,38 @@ const mapToMetricsViewModel = (metrics, defaultLabel) =>
...metric,
}));
/**
* Maps an axis view model
*
* Defaults to a 2 digit precision and `number` format. It only allows
* formats in the SUPPORTED_FORMATS array.
*
* @param {Object} axis
*/
const mapToAxisViewModel = ({ name = '', format = SUPPORTED_FORMATS.number, precision = 2 }) => {
return {
name,
format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.number,
precision,
};
};
/**
* Maps a metrics panel to its view model
*
* @param {Object} panel - Metrics panel
* @returns {Object}
*/
const mapToPanelViewModel = ({ title = '', type, y_label, metrics = [] }) => {
const mapToPanelViewModel = ({ title = '', type, y_label, y_axis = {}, metrics = [] }) => {
// Both `y_axis.name` and `y_label` are supported for now
// https://gitlab.com/gitlab-org/gitlab/issues/208385
const yAxis = mapToAxisViewModel({ name: y_label, ...y_axis }); // eslint-disable-line babel/camelcase
return {
title,
type,
y_label,
metrics: mapToMetricsViewModel(metrics, y_label),
y_label: yAxis.name, // Changing y_label to yLabel is pending https://gitlab.com/gitlab-org/gitlab/issues/207198
yAxis,
metrics: mapToMetricsViewModel(metrics, yAxis.name),
};
};
......
import _ from 'underscore';
import { throttle } from 'lodash';
import $ from 'jquery';
import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
......@@ -85,7 +85,7 @@ export default class GLTerminal {
addScrollListener(onScrollLimit) {
const viewport = this.container.querySelector('.xterm-viewport');
const listener = _.throttle(() => {
const listener = throttle(() => {
onScrollLimit({
canScrollUp: canScrollUp(viewport, SCROLL_MARGIN),
canScrollDown: canScrollDown(viewport, SCROLL_MARGIN),
......
import $ from 'jquery';
import _ from 'underscore';
import { template as lodashTemplate, omit } from 'lodash';
import importU2FLibrary from './util';
import U2FError from './error';
......@@ -37,7 +37,7 @@ export default class U2FAuthenticate {
// Note: The server library fixes this behaviour in (unreleased) version 1.0.0.
// This can be removed once we upgrade.
// https://github.com/castle/ruby-u2f/commit/103f428071a81cd3d5f80c2e77d522d5029946a4
this.signRequests = u2fParams.sign_requests.map(request => _(request).omit('challenge'));
this.signRequests = u2fParams.sign_requests.map(request => omit(request, 'challenge'));
this.templates = {
setup: '#js-authenticate-u2f-setup',
......@@ -74,7 +74,7 @@ export default class U2FAuthenticate {
renderTemplate(name, params) {
const templateString = $(this.templates[name]).html();
const template = _.template(templateString);
const template = lodashTemplate(templateString);
return this.container.html(template(params));
}
......
import $ from 'jquery';
import _ from 'underscore';
import { template as lodashTemplate } from 'lodash';
import importU2FLibrary from './util';
import U2FError from './error';
......@@ -59,7 +59,7 @@ export default class U2FRegister {
renderTemplate(name, params) {
const templateString = $(this.templates[name]).html();
const template = _.template(templateString);
const template = lodashTemplate(templateString);
return this.container.html(template(params));
}
......
......@@ -53,6 +53,7 @@ export default {
.then(res => res.data)
.then(data => {
eventHub.$emit('UpdateWidgetData', data);
eventHub.$emit('MRWidgetUpdateRequested');
})
.catch(() => {
this.isCancellingAutoMerge = false;
......
......@@ -123,13 +123,15 @@ export default class MergeRequestStore {
const currentUser = data.current_user;
this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
this.revertInForkPath = currentUser.revert_in_fork_path;
this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
this.canCreateIssue = currentUser.can_create_issue || false;
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
if (currentUser) {
this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
this.revertInForkPath = currentUser.revert_in_fork_path;
this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
this.canCreateIssue = currentUser.can_create_issue || false;
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
}
this.setState(data);
}
......
......@@ -98,6 +98,14 @@
}
}
.refresh-dashboard-button {
margin-top: 22px;
@media(max-width: map-get($grid-breakpoints, sm)) {
margin-top: 0;
}
}
.metric-area {
opacity: 0.25;
}
......
......@@ -117,6 +117,7 @@ class ProfilesController < Profiles::ApplicationController
:private_profile,
:include_private_contributions,
:timezone,
:job_title,
status: [:emoji, :message]
)
end
......
......@@ -66,7 +66,7 @@ module Projects
[
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path,
:auto_cancel_pending_pipelines, :forward_deployment_enabled, :ci_config_path,
auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy],
ci_cd_settings_attributes: [:default_git_depth]
].tap do |list|
......
......@@ -38,7 +38,7 @@ class Appearance < ApplicationRecord
def single_appearance_row
if self.class.any?
errors.add(:single_appearance_row, 'Only 1 appearances row can exist')
errors.add(:base, _('Only 1 appearances row can exist'))
end
end
......
......@@ -389,7 +389,7 @@ module ApplicationSettingImplementation
def terms_exist
return unless enforce_terms?
errors.add(:terms, "You need to set terms to be enforced") unless terms.present?
errors.add(:base, _('You need to set terms to be enforced')) unless terms.present?
end
def expire_performance_bar_allowed_user_ids_cache
......
......@@ -148,7 +148,7 @@ module Ci
def valid_file_format?
unless TYPE_AND_FORMAT_PAIRS[self.file_type&.to_sym] == self.file_format&.to_sym
errors.add(:file_format, 'Invalid file format with specified file type')
errors.add(:base, _('Invalid file format with specified file type'))
end
end
......
......@@ -3,7 +3,7 @@
module Clusters
module Applications
class Ingress < ApplicationRecord
VERSION = '1.29.3'
VERSION = '1.29.7'
MODSECURITY_LOG_CONTAINER_NAME = 'modsecurity-log'
self.table_name = 'clusters_applications_ingress'
......
......@@ -306,7 +306,7 @@ module Clusters
.where.not(id: id)
if duplicate_management_clusters.any?
errors.add(:environment_scope, "cannot add duplicated environment scope")
errors.add(:environment_scope, 'cannot add duplicated environment scope')
end
end
......@@ -380,7 +380,7 @@ module Clusters
def restrict_modification
if provider&.on_creation?
errors.add(:base, "cannot modify during creation")
errors.add(:base, _('Cannot modify provider during creation'))
return false
end
......
......@@ -19,7 +19,7 @@ module HasRepository
def valid_repo?
repository.exists?
rescue
errors.add(:path, _('Invalid repository path'))
errors.add(:base, _('Invalid repository path'))
false
end
......
......@@ -37,7 +37,7 @@ module Milestoneable
private
def milestone_is_valid
errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available?
errors.add(:milestone_id, 'is invalid') if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available?
end
end
......
......@@ -77,7 +77,7 @@ module TimeTrackable
return if time_spent.nil? || time_spent == :reset
if time_spent < 0 && (time_spent.abs > original_total_time_spent)
errors.add(:time_spent, 'Time to subtract exceeds the total time spent')
errors.add(:base, _('Time to subtract exceeds the total time spent'))
end
end
......
......@@ -105,7 +105,7 @@ class DeployToken < ApplicationRecord
end
def ensure_at_least_one_scope
errors.add(:base, "Scopes can't be blank") unless read_repository || read_registry
errors.add(:base, _("Scopes can't be blank")) unless read_repository || read_registry
end
def default_username
......
......@@ -19,7 +19,13 @@ class DescriptionVersion < ApplicationRecord
def exactly_one_issuable
issuable_count = self.class.issuable_attrs.count { |attr| self["#{attr}_id"] }
errors.add(:base, "Exactly one of #{self.class.issuable_attrs.join(', ')} is required") if issuable_count != 1
if issuable_count != 1
errors.add(
:base,
_("Exactly one of %{attributes} is required") %
{ attributes: self.class.issuable_attrs.join(', ') }
)
end
end
end
......
......@@ -78,7 +78,7 @@ class ExternalPullRequest < ApplicationRecord
def not_from_fork
if from_fork?
errors.add(:base, 'Pull requests from fork are not supported')
errors.add(:base, _('Pull requests from fork are not supported'))
end
end
......
......@@ -11,6 +11,6 @@ class MilestoneRelease < ApplicationRecord
def same_project_between_milestone_and_release
return if milestone&.project_id == release&.project_id
errors.add(:base, 'does not have the same project as the milestone')
errors.add(:base, _('Release does not have the same project as the milestone'))
end
end
......@@ -376,7 +376,7 @@ class Namespace < ApplicationRecord
def nesting_level_allowed
if ancestors.count > Group::NUMBER_OF_ANCESTORS_ALLOWED
errors.add(:parent_id, "has too deep level of nesting")
errors.add(:parent_id, 'has too deep level of nesting')
end
end
......
......@@ -31,7 +31,7 @@ class ProjectCiCdSetting < ApplicationRecord
end
def forward_deployment_enabled?
super && ::Feature.enabled?(:forward_deployment_enabled, project)
super && ::Feature.enabled?(:forward_deployment_enabled, project, default_enabled: true)
end
private
......
......@@ -168,7 +168,7 @@ class IssueTrackerService < Service
return if project.blank?
if project.services.external_issue_trackers.where.not(id: id).any?
errors.add(:base, 'Another issue tracker is already in use. Only one issue tracker service can be active at a time')
errors.add(:base, _('Another issue tracker is already in use. Only one issue tracker service can be active at a time'))
end
end
end
......
......@@ -69,13 +69,13 @@ class PrometheusAlert < ApplicationRecord
def require_valid_environment_project!
return if project == environment&.project
errors.add(:environment, "invalid project")
errors.add(:environment, 'invalid project')
end
def require_valid_metric_project!
return if prometheus_metric&.common?
return if project == prometheus_metric&.project
errors.add(:prometheus_metric, "invalid project")
errors.add(:prometheus_metric, 'invalid project')
end
end
......@@ -37,6 +37,9 @@ class ResourceEvent < ApplicationRecord
return true if issuable_count == 1
end
errors.add(:base, "Exactly one of #{self.class.issuable_attrs.join(', ')} is required")
errors.add(
:base, _("Exactly one of %{attributes} is required") %
{ attributes: self.class.issuable_attrs.join(', ') }
)
end
end
......@@ -111,7 +111,10 @@ class SentNotification < ApplicationRecord
note = create_reply('Test', dryrun: true)
unless note.valid?
self.errors.add(:base, "Note parameters are invalid: #{note.errors.full_messages.to_sentence}")
self.errors.add(
:base, _("Note parameters are invalid: %{errors}") %
{ errors: note.errors.full_messages.to_sentence }
)
end
end
......
......@@ -28,9 +28,9 @@ class Timelog < ApplicationRecord
def issuable_id_is_present
if issue_id && merge_request_id
errors.add(:base, 'Only Issue ID or Merge Request ID is required')
errors.add(:base, _('Only Issue ID or Merge Request ID is required'))
elsif issuable.nil?
errors.add(:base, 'Issue or Merge Request ID is required')
errors.add(:base, _('Issue or Merge Request ID is required'))
end
end
......
......@@ -162,6 +162,7 @@ class User < ApplicationRecord
has_one :status, class_name: 'UserStatus'
has_one :user_preference
has_one :user_detail
#
# Validations
......@@ -259,8 +260,10 @@ class User < ApplicationRecord
delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference
delegate :setup_for_company, :setup_for_company=, to: :user_preference
delegate :render_whitespace_in_code, :render_whitespace_in_code=, to: :user_preference
delegate :job_title, :job_title=, to: :user_detail, allow_nil: true
accepts_nested_attributes_for :user_preference, update_only: true
accepts_nested_attributes_for :user_detail, update_only: true
state_machine :state, initial: :active do
event :block do
......@@ -1619,6 +1622,10 @@ class User < ApplicationRecord
super.presence || build_user_preference
end
def user_detail
super.presence || build_user_detail
end
def todos_limited_to(ids)
todos.where(id: ids)
end
......
# frozen_string_literal: true
class UserDetail < ApplicationRecord
belongs_to :user
validates :job_title, presence: true, length: { maximum: 200 }
end
......@@ -88,6 +88,15 @@
= _("New pipelines will cancel older, pending pipelines on the same branch")
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'auto-cancel-pending-pipelines'), target: '_blank'
.form-group
.form-check
= f.check_box :forward_deployment_enabled, { class: 'form-check-input' }
= f.label :forward_deployment_enabled, class: 'form-check-label' do
%strong= _("Skip older, pending deployment jobs")
.form-text.text-muted
= _("When a deployment job is successful, skip older deployment jobs that are still pending")
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'skip-older-pending-deployment-jobs'), target: '_blank'
%hr
.form-group
= f.label :build_coverage_regex, _("Test coverage parsing"), class: 'label-bold'
......
---
title: Add properties to the dashboard definition to customize y-axis format
merge_request: 25785
author:
type: added
---
title: Added Drop older active deployments project setting
merge_request: 25520
author:
type: added
---
title: 'Issue Analytics: Fix svg illustration path for empty state'
merge_request: 26219
author:
type: fixed
---
title: Add support for user Job Title
merge_request: 25483
author:
type: added
---
title: Add refresh dashboard button
merge_request: 25716
author:
type: changed
---
title: Fix search for Sentry error list
merge_request: 26129
author:
type: fixed
---
title: Refresh widget after canceling "Merge When Pipeline Succeeds"
merge_request: 26232
author:
type: fixed
---
title: 'Update Ingress chart version to 1.29.7'
merge_request: 25949
author:
type: added
......@@ -20,6 +20,8 @@ en:
token: "Grafana HTTP API Token"
grafana_url: "Grafana API URL"
grafana_enabled: "Grafana integration enabled"
user/user_detail:
job_title: 'Job title'
views:
pagination:
previous: "Prev"
......
# frozen_string_literal: true
class CreateUserDetails < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
with_lock_retries do
create_table :user_details, id: false do |t|
t.references :user, index: false, foreign_key: { on_delete: :cascade }, null: false, primary_key: true
t.string :job_title, limit: 200, default: "", null: false
end
end
add_index :user_details, :user_id, unique: true
end
end
......@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_02_26_162723) do
ActiveRecord::Schema.define(version: 2020_02_27_165129) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
......@@ -4170,6 +4170,11 @@ ActiveRecord::Schema.define(version: 2020_02_26_162723) do
t.index ["user_id", "key"], name: "index_user_custom_attributes_on_user_id_and_key", unique: true
end
create_table "user_details", primary_key: "user_id", force: :cascade do |t|
t.string "job_title", limit: 200, default: "", null: false
t.index ["user_id"], name: "index_user_details_on_user_id", unique: true
end
create_table "user_interacted_projects", id: false, force: :cascade do |t|
t.integer "user_id", null: false
t.integer "project_id", null: false
......@@ -5028,6 +5033,7 @@ ActiveRecord::Schema.define(version: 2020_02_26_162723) do
add_foreign_key "u2f_registrations", "users"
add_foreign_key "user_callouts", "users", on_delete: :cascade
add_foreign_key "user_custom_attributes", "users", on_delete: :cascade
add_foreign_key "user_details", "users", on_delete: :cascade
add_foreign_key "user_interacted_projects", "projects", name: "fk_722ceba4f7", on_delete: :cascade
add_foreign_key "user_interacted_projects", "users", name: "fk_0894651f08", on_delete: :cascade
add_foreign_key "user_preferences", "users", on_delete: :cascade
......
......@@ -75,7 +75,7 @@ GitLab stores files and blobs such as Issue attachments or LFS objects into eith
- The filesystem in a specific location.
- An Object Storage solution. Object Storage solutions can be:
- Cloud based like Amazon S3 Google Cloud Storage.
- Self hosted (like MinIO).
- Hosted by you (like MinIO).
- A Storage Appliance that exposes an Object Storage-compatible API.
When using the filesystem store instead of Object Storage, you need to use network mounted filesystems
......
......@@ -72,10 +72,10 @@ If a new pipeline would cause the total number of jobs to exceed the limit, the
will fail with a `job_activity_limit_exceeded` error.
- On GitLab.com different [limits are defined per plan](../user/gitlab_com/index.md#gitlab-cicd) and they affect all projects under that plan.
- On [GitLab Starter](https://about.gitlab.com/pricing/#self-managed) tier or higher self-hosted installations, this limit is defined for the `default` plan that affects all projects.
- On [GitLab Starter](https://about.gitlab.com/pricing/#self-managed) tier or higher self-managed installations, this limit is defined for the `default` plan that affects all projects.
This limit is disabled by default.
To set this limit on a self-hosted installation, run the following in the
To set this limit on a self-managed installation, run the following in the
[GitLab Rails console](https://docs.gitlab.com/omnibus/maintenance/#starting-a-rails-console-session):
```ruby
......@@ -113,9 +113,9 @@ text field exceeds this limit then the text will be truncated to this number of
characters and the rest will not be indexed and hence will not be searchable.
- On GitLab.com this is limited to 20000 characters
- For self-hosted installations it is unlimited by default
- For self-managed installations it is unlimited by default
This limit can be configured for self hosted installations when [enabling
This limit can be configured for self-managed installations when [enabling
Elasticsearch](../integration/elasticsearch.md#enabling-elasticsearch).
NOTE: **Note:** Set the limit to `0` to disable it.
......
......@@ -9,7 +9,7 @@ GitLab by default supports the [Gravatar](https://gravatar.com) avatar service.
Libravatar is another service that delivers your avatar (profile picture) to
other websites. The Libravatar API is
[heavily based on gravatar](https://wiki.libravatar.org/api/), so you can
easily switch to the Libravatar avatar service or even a self-hosted Libravatar
easily switch to the Libravatar avatar service or even your own Libravatar
server.
## Configuration
......@@ -35,7 +35,7 @@ the configuration options as follows:
ssl_url: "https://seccdn.libravatar.org/avatar/%{hash}?s=%{size}&d=identicon"
```
### Self-hosted Libravatar server
### Your own Libravatar server
If you are [running your own libravatar service](https://wiki.libravatar.org/running_your_own/),
the URL will be different in the configuration, but you must provide the same
......
......@@ -95,6 +95,7 @@ GET /users
"twitter": "",
"website_url": "",
"organization": "",
"job_title": "",
"last_sign_in_at": "2012-06-01T11:41:01Z",
"confirmed_at": "2012-05-23T09:05:22Z",
"theme_id": 1,
......@@ -132,6 +133,7 @@ GET /users
"twitter": "",
"website_url": "",
"organization": "",
"job_title": "",
"last_sign_in_at": null,
"confirmed_at": "2012-05-30T16:53:06.148Z",
"theme_id": 1,
......@@ -247,7 +249,8 @@ Parameters:
"linkedin": "",
"twitter": "",
"website_url": "",
"organization": ""
"organization": "",
"job_title": "Operations Specialist"
}
```
......@@ -282,6 +285,7 @@ Example Responses:
"twitter": "",
"website_url": "",
"organization": "",
"job_title": "Operations Specialist",
"last_sign_in_at": "2012-06-01T11:41:01Z",
"confirmed_at": "2012-05-23T09:05:22Z",
"theme_id": 1,
......@@ -545,6 +549,7 @@ GET /user
"twitter": "",
"website_url": "",
"organization": "",
"job_title": "",
"last_sign_in_at": "2012-06-01T11:41:01Z",
"confirmed_at": "2012-05-23T09:05:22Z",
"theme_id": 1,
......
......@@ -693,7 +693,7 @@ To configure credentials store, follow these steps:
}
```
- Or, if you are running self-hosted Runners, add the above JSON to
- Or, if you are running self-managed Runners, add the above JSON to
`${GITLAB_RUNNER_HOME}/.docker/config.json`. GitLab Runner will read this config file
and will use the needed helper for this specific repository.
......@@ -726,7 +726,7 @@ To configure access for `aws_account_id.dkr.ecr.region.amazonaws.com`, follow th
}
```
- Or, if you are running self-hosted Runners,
- Or, if you are running self-managed Runners,
add the above JSON to `${GITLAB_RUNNER_HOME}/.docker/config.json`.
GitLab Runner will read this config file and will use the needed helper for this
specific repository.
......
......@@ -44,7 +44,7 @@ Complementary reads:
- [Guidelines for implementing Enterprise Edition features](ee_features.md)
- [Danger bot](dangerbot.md)
- [Generate a changelog entry with `bin/changelog`](changelog.md)
- [Requesting access to Chatops on GitLab.com](chatops_on_gitlabcom.md#requesting-access) (for GitLabbers)
- [Requesting access to Chatops on GitLab.com](chatops_on_gitlabcom.md#requesting-access) (for GitLab team members)
## UX and Frontend guides
......
# Delete existing migrations
When removing existing migrations from the GitLab project, you have to take into account
the possibility of the migration already been included in past releases or in the current release, and thus already executed on GitLab.com and/or in self-hosted instances.
the possibility of the migration already been included in past releases or in the current release, and thus already executed on GitLab.com and/or in self-managed instances.
Because of it, it's not possible to delete existing migrations, as that could lead to:
......
......@@ -147,7 +147,7 @@ is always on or off to the users.
## Cleaning up
Once the change is deemed stable, submit a new merge request to remove the
feature flag. This ensures the change is available to all users and self-hosted
feature flag. This ensures the change is available to all users and self-managed
instances. Make sure to add the ~"feature flag" label to this merge request so
release managers are aware the changes are hidden behind a feature flag. If the
merge request has to be picked into a stable branch, make sure to also add the
......
......@@ -50,7 +50,7 @@ The reason we spread this out across three releases is that dropping a column is
a destructive operation that can't be rolled back easily.
Following this procedure helps us to make sure there are no deployments to GitLab.com
and upgrade processes for self-hosted installations that lump together any of these steps.
and upgrade processes for self-managed installations that lump together any of these steps.
### Step 1: Ignoring the column (release M)
......
......@@ -121,7 +121,7 @@ With the [Customers Portal](https://customers.gitlab.com/) you can:
To change billing information:
1. Log in to [Customers Portal](https://customers.gitlab.com/customers/sign_in).
1. Log in to the [Customers Portal](https://customers.gitlab.com/customers/sign_in).
1. Go to the **My Account** page.
1. Make the required changes to the **Account Details** information.
1. Click **Update Account**.
......@@ -143,7 +143,7 @@ account:
1. On the Customers Portal page, click
[**My Account**](https://customers.gitlab.com/customers/edit) in the top menu.
1. Under **Your GitLab.com account**, click **Change linked account** button.
1. Log in to the [GitLab.com](https://gitlab.com) account you want to link to Customers Portal.
1. Log in to the [GitLab.com](https://gitlab.com) account you want to link to the Customers Portal.
### Change the associated namespace
......@@ -195,9 +195,9 @@ The [Customers Portal](https://customers.gitlab.com/customers/sign_in) is your t
TIP: **Tip:**
Contact our [support team](https://support.gitlab.com/hc/en-us/requests/new?ticket_form_id=360000071293) if you need assistance accessing the Customers Portal or if you need to change the contact person who manages your subscription.
Check who is accessing your system. Are there user accounts which are no longer active? It's important to regularly review your GitLab user accounts because:
It's important to regularly review your user accounts, because:
- A GitLab subscription is based on the number of users. Renewing a subscription for too many users results in you paying more than you should. Attempting to renew a subscription for too few users will result in the renewal failing.
- A GitLab subscription is based on the number of users. You will pay more than you should if you renew for too many users, while the renewal will fail if you attempt to renew a subscription for too few users.
- Stale user accounts can be a security risk. A regular review helps reduce this risk.
#### Users over License
......@@ -219,7 +219,7 @@ Self-managed instances can add users to a subscription any time during the subsc
To add users to a subscription:
1. Log in to [Customers Portal](https://customers.gitlab.com/).
1. Log in to the [Customers Portal](https://customers.gitlab.com/).
1. Select **Manage Purchases**.
1. Select **Add more seats**.
1. Enter the number of additional users.
......@@ -234,7 +234,7 @@ The following will be emailed to you:
### Renew or change a GitLab.com subscription
To renew for more users than are currently active in your GitLab.com system, contact our sales team via `renewals@gitlab.com` for assistance as this can't be done in Customers Portal.
To renew for more users than are currently active in your GitLab.com system, contact our sales team via `renewals@gitlab.com` for assistance as this can't be done in the Customers Portal.
To change the [GitLab tier](https://about.gitlab.com/pricing/), select **Upgrade** under your subscription on the [My Account](https://customers.gitlab.com/subscriptions) page.
......@@ -259,13 +259,13 @@ We recommend following these steps during renewal:
1. Log in to the [Customers Portal](https://customers.gitlab.com/customers/sign_in) and select the **Renew** button beneath your existing subscription.
TIP: **Tip:**
If you need to change your [GitLab tier](https://about.gitlab.com/pricing/), contact our sales team via `renewals@gitlab.com` for assistance as this can't be done in Customers Portal.
If you need to change your [GitLab tier](https://about.gitlab.com/pricing/), contact our sales team via `renewals@gitlab.com` for assistance as this can't be done in the Customers Portal.
1. In the first box, enter the total number of user licenses you’ll need for the upcoming year. Be sure this number is at least **equal to, or greater than** the number of active users in the system at the time of performing the renewal.
1. Enter the number of [users over license](#users-over-license) in the second box for the user overage incurred in your previous subscription term.
TIP: **Tip:**
You can find the _users over license_ in your instance's **Admin** dashboard by clicking on {**admin**} (**Admin Area**) in the top bar, or going to `/admin`.
You can find the _users over license_ in your instance's **Admin** dashboard by clicking on **{admin}** (**Admin Area**) in the top bar, or going to `/admin`.
1. Review your renewal details and complete the payment process.
1. A license for the renewal term will be available on the [Manage Purchases](https://customers.gitlab.com/subscriptions) page beneath your new subscription details.
......@@ -294,13 +294,11 @@ CI pipeline minutes are the execution time for your [pipelines](../ci/pipelines.
Quotas apply to:
- Groups, where the minutes are shared across all members of the group, its subgroups, and nested projects. To view the group's usage, navigate to the group's page, then **Settings > Usage Quotas**.
- Your personal account, where the minutes are available for your personal projects. To view and buy personal minutes, click your avatar, then **Settings > Pipeline quota**.
- Groups, where the minutes are shared across all members of the group, its subgroups, and nested projects. To view the group's usage, navigate to the group, then **{settings}** **Settings > Usage Quotas**.
- Your personal account, where the minutes are available for your personal projects. To view and buy personal minutes, click your avatar, then **{settings}** **Settings > Pipeline quota**.
Only pipeline minutes for GitLab shared runners are restricted. If you have a specific runner set up for your projects, there is no limit to your build time on GitLab.com.
The minutes limit does not apply to public projects.
The available quota is reset on the first of each calendar month at midnight UTC.
When the CI minutes are depleted, an email is sent automatically to notify the owner(s)
......@@ -317,10 +315,10 @@ main quota. Additional minutes:
To purchase additional minutes for your group on GitLab.com:
1. From your group, go to **Settings > Pipeline quota**.
1. From your group, go to **{settings}** **Settings > Usage Quotas**.
1. Locate the subscription card that's linked to your group on GitLab.com, click **Buy more CI minutes**, and complete the details about the transaction.
1. Once we have processed your payment, the extra CI minutes will be synced to your group.
1. To confirm the available CI minutes, go to **Group > Settings > Pipelines quota**.
1. To confirm the available CI minutes, go to your group, then **{settings}** **Settings > Usage Quotas**.
The **Additional minutes** displayed now includes the purchased additional CI minutes, plus any minutes rolled over from last month.
To purchase additional minutes for your personal namespace:
......
......@@ -191,6 +191,25 @@ you can enable this in the project settings:
1. Check the **Auto-cancel redundant, pending pipelines** checkbox.
1. Click **Save changes**.
## Skip older, pending deployment jobs
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/25276) in GitLab 12.9.
Your project may have multiple concurrent deployment jobs that are
scheduled to run within the same time frame.
This can lead to a situation where an older deployment job runs after a
newer one, which may not be what you want.
To avoid this scenario:
1. Go to **{settings}** **Settings > CI / CD**.
1. Expand **General pipelines**.
1. Check the **Skip older, pending deployment jobs** checkbox.
1. Click **Save changes**.
The pending deployment jobs will be skipped.
## Pipeline Badges
In the pipelines settings page you can find pipeline status and test coverage
......
......@@ -4,7 +4,7 @@ module API
module Entities
class User < UserBasic
expose :created_at, if: ->(user, opts) { Ability.allowed?(opts[:current_user], :read_user_profile, user) }
expose :bio, :location, :public_email, :skype, :linkedin, :twitter, :website_url, :organization
expose :bio, :location, :public_email, :skype, :linkedin, :twitter, :website_url, :organization, :job_title
end
end
end
......@@ -1281,6 +1281,9 @@ msgstr ""
msgid "Admin notes"
msgstr ""
msgid "AdminArea|Included Free in license"
msgstr ""
msgid "AdminArea|Stop all jobs"
msgstr ""
......@@ -2012,6 +2015,9 @@ msgstr ""
msgid "Anonymous"
msgstr ""
msgid "Another issue tracker is already in use. Only one issue tracker service can be active at a time"
msgstr ""
msgid "Anti-spam verification"
msgstr ""
......@@ -3248,6 +3254,9 @@ msgstr ""
msgid "Cannot modify managed Kubernetes cluster"
msgstr ""
msgid "Cannot modify provider during creation"
msgstr ""
msgid "Cannot refer to a group milestone by an internal id!"
msgstr ""
......@@ -5817,6 +5826,9 @@ msgstr ""
msgid "Current node"
msgstr ""
msgid "Current node must be the primary node or you will be locking yourself out"
msgstr ""
msgid "Current password"
msgstr ""
......@@ -8018,6 +8030,9 @@ msgstr ""
msgid "Evidence collection"
msgstr ""
msgid "Exactly one of %{attributes} is required"
msgstr ""
msgid "Example: @sub\\.company\\.com$"
msgstr ""
......@@ -9776,6 +9791,9 @@ msgstr ""
msgid "Group pipeline minutes were successfully reset."
msgstr ""
msgid "Group requires separate account"
msgstr ""
msgid "Group variables (inherited)"
msgstr ""
......@@ -10121,6 +10139,9 @@ msgstr ""
msgid "HTTP Basic: Access denied\\nYou must use a personal access token with 'api' scope for Git over HTTP.\\nYou can generate one at %{profile_personal_access_tokens_url}"
msgstr ""
msgid "Hashed Storage must be enabled to use Geo"
msgstr ""
msgid "Hashed repository storage paths"
msgstr ""
......@@ -10755,6 +10776,9 @@ msgstr ""
msgid "Invalid field"
msgstr ""
msgid "Invalid file format with specified file type"
msgstr ""
msgid "Invalid file."
msgstr ""
......@@ -10848,6 +10872,9 @@ msgstr ""
msgid "Issue events"
msgstr ""
msgid "Issue or Merge Request ID is required"
msgstr ""
msgid "Issue template (optional)"
msgstr ""
......@@ -12387,6 +12414,9 @@ msgstr ""
msgid "Metrics|Prometheus Query Documentation"
msgstr ""
msgid "Metrics|Reload this page"
msgstr ""
msgid "Metrics|Show last"
msgstr ""
......@@ -12432,6 +12462,9 @@ msgstr ""
msgid "Metrics|Validating query"
msgstr ""
msgid "Metrics|Values"
msgstr ""
msgid "Metrics|View logs"
msgstr ""
......@@ -13141,6 +13174,9 @@ msgstr ""
msgid "Note"
msgstr ""
msgid "Note parameters are invalid: %{errors}"
msgstr ""
msgid "Note that this invitation was sent to %{mail_to_invite_email}, but you are signed in as %{link_to_current_user} with email %{mail_to_current_user}."
msgstr ""
......@@ -13374,6 +13410,12 @@ msgstr ""
msgid "Only 'Reporter' roles and above on tiers Premium / Silver and above can see Value Stream Analytics."
msgstr ""
msgid "Only 1 appearances row can exist"
msgstr ""
msgid "Only Issue ID or Merge Request ID is required"
msgstr ""
msgid "Only Project Members"
msgstr ""
......@@ -13548,12 +13590,24 @@ msgstr ""
msgid "Package Registry"
msgstr ""
msgid "Package already exists"
msgstr ""
msgid "Package deleted successfully"
msgstr ""
msgid "Package information"
msgstr ""
msgid "Package recipe already exists"
msgstr ""
msgid "Package type must be Conan"
msgstr ""
msgid "Package type must be Maven"
msgstr ""
msgid "Package was removed"
msgstr ""
......@@ -15753,6 +15807,9 @@ msgstr ""
msgid "Pull"
msgstr ""
msgid "Pull requests from fork are not supported"
msgstr ""
msgid "Puma is running with a thread count above 1 and the Rugged service is enabled. This may decrease performance in some environments. See our %{link_start}documentation%{link_end} for details of this issue."
msgstr ""
......@@ -16015,6 +16072,9 @@ msgid_plural "Releases"
msgstr[0] ""
msgstr[1] ""
msgid "Release does not have the same project as the milestone"
msgstr ""
msgid "Release notes"
msgstr ""
......@@ -16834,6 +16894,9 @@ msgstr ""
msgid "Scopes"
msgstr ""
msgid "Scopes can't be blank"
msgstr ""
msgid "Scroll down"
msgstr ""
......@@ -17908,6 +17971,9 @@ msgstr ""
msgid "Size settings for static websites"
msgstr ""
msgid "Skip older, pending deployment jobs"
msgstr ""
msgid "Skip this for now"
msgstr ""
......@@ -19349,6 +19415,9 @@ msgstr ""
msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
msgstr ""
msgid "The license key is invalid. Make sure it is exactly as you received it from GitLab Inc."
msgstr ""
msgid "The license was removed. GitLab has fallen back on the previous license."
msgstr ""
......@@ -20003,6 +20072,9 @@ msgstr ""
msgid "This job will automatically run after its timer finishes. Often they are used for incremental roll-out deploys to production environments. When unscheduled it converts into a manual action."
msgstr ""
msgid "This license has already expired."
msgstr ""
msgid "This may expose confidential information as the selected fork is in another namespace that can have other members."
msgstr ""
......@@ -20114,6 +20186,9 @@ msgstr ""
msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
msgstr ""
msgid "Thread to reply to cannot be found"
msgstr ""
msgid "Threat Monitoring"
msgstr ""
......@@ -20210,6 +20285,9 @@ msgstr ""
msgid "Time to merge"
msgstr ""
msgid "Time to subtract exceeds the total time spent"
msgstr ""
msgid "Time tracking"
msgstr ""
......@@ -21288,6 +21366,9 @@ msgstr ""
msgid "User identity was successfully updated."
msgstr ""
msgid "User is not allowed to resolve thread"
msgstr ""
msgid "User key was successfully removed."
msgstr ""
......@@ -22043,6 +22124,9 @@ msgstr ""
msgid "What are you searching for?"
msgstr ""
msgid "When a deployment job is successful, skip older deployment jobs that are still pending"
msgstr ""
msgid "When a runner is locked, it cannot be assigned to other projects"
msgstr ""
......@@ -22600,6 +22684,9 @@ msgstr ""
msgid "You need to register a two-factor authentication app before you can set up a U2F device."
msgstr ""
msgid "You need to set terms to be enforced"
msgstr ""
msgid "You need to specify both an Access Token and a Host URL."
msgstr ""
......
......@@ -89,6 +89,16 @@ describe ProfilesController, :request_store do
expect(user.reload.status.message).to eq('Working hard!')
expect(response).to have_gitlab_http_status(:found)
end
it 'allows updating user specified job title' do
title = 'Marketing Executive'
sign_in(user)
put :update, params: { user: { job_title: title } }
expect(user.reload.job_title).to eq(title)
expect(response).to have_gitlab_http_status(:found)
end
end
describe 'PUT update_username' do
......
# frozen_string_literal: true
FactoryBot.define do
factory :user_detail do
user
job_title { 'VP of Sales' }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'When a user searches for Sentry errors', :js, :use_clean_rails_memory_store_caching, :sidekiq_inline do
include_context 'sentry error tracking context feature'
let_it_be(:issues_response_body) { fixture_file('sentry/issues_sample_response.json') }
let_it_be(:error_search_response_body) { fixture_file('sentry/error_list_search_response.json') }
let(:issues_api_url) { "#{sentry_api_urls.issues_url}?limit=20&query=is:unresolved" }
let(:issues_api_url_search) { "#{sentry_api_urls.issues_url}?limit=20&query=is:unresolved%20NotFound" }
before do
stub_request(:get, issues_api_url).with(
headers: { 'Authorization' => 'Bearer access_token_123' }
).to_return(status: 200, body: issues_response_body, headers: { 'Content-Type' => 'application/json' })
stub_request(:get, issues_api_url_search).with(
headers: { 'Authorization' => 'Bearer access_token_123', 'Content-Type' => 'application/json' }
).to_return(status: 200, body: error_search_response_body, headers: { 'Content-Type' => 'application/json' })
end
it 'displays the results' do
sign_in(project.owner)
visit project_error_tracking_index_path(project)
page.within(find('.gl-table')) do
results = page.all('.table-row')
expect(results.count).to be(2)
end
find('.gl-form-input').set('NotFound').native.send_keys(:return)
page.within(find('.gl-table')) do
results = page.all('.table-row')
expect(results.count).to be(1)
expect(results.first).to have_content('NotFound')
end
end
end
......@@ -64,6 +64,10 @@ describe 'Merge request > User merges when pipeline succeeds', :js do
before do
click_button "Merge when pipeline succeeds"
click_link "Cancel automatic merge"
wait_for_requests
expect(page).to have_content 'Merge when pipeline succeeds', wait: 0
end
it_behaves_like 'Merge when pipeline succeeds activator'
......
......@@ -61,6 +61,28 @@ describe "Projects > Settings > Pipelines settings" do
expect(checkbox).to be_checked
end
it 'updates forward_deployment_enabled' do
visit project_settings_ci_cd_path(project)
checkbox = find_field('project_forward_deployment_enabled')
expect(checkbox).to be_checked
checkbox.set(false)
page.within '#js-general-pipeline-settings' do
click_on 'Save changes'
end
expect(page.status_code).to eq(200)
page.within '#js-general-pipeline-settings' do
expect(page).to have_button('Save changes', disabled: false)
end
checkbox = find_field('project_forward_deployment_enabled')
expect(checkbox).not_to be_checked
end
describe 'Auto DevOps' do
context 'when auto devops is turned on instance-wide' do
before do
......
[{
"lastSeen": "2018-12-31T12:00:11Z",
"numComments": 0,
"userCount": 0,
"stats": {
"24h": [
[
1546437600,
0
]
]
},
"culprit": "sentry.tasks.reports.deliver_organization_user_report",
"title": "NotFound desc = GetRepoPath: not a git repository",
"id": "13",
"assignedTo": null,
"logger": null,
"type": "error",
"annotations": [],
"metadata": {
"type": "gaierror",
"value": "[Errno -2] Name or service not known"
},
"status": "unresolved",
"subscriptionDetails": null,
"isPublic": false,
"hasSeen": false,
"shortId": "INTERNAL-4",
"shareId": null,
"firstSeen": "2018-12-17T12:00:14Z",
"count": "17283712",
"permalink": "35.228.54.90/sentry/internal/issues/13/",
"level": "error",
"isSubscribed": true,
"isBookmarked": false,
"project": {
"slug": "internal",
"id": "1",
"name": "Internal"
},
"statusDetails": {}
}]
[{
[
{
"lastSeen": "2018-12-31T12:00:11Z",
"numComments": 0,
"userCount": 0,
......@@ -39,4 +40,47 @@
"name": "Internal"
},
"statusDetails": {}
}]
},
{
"lastSeen": "2018-12-31T12:00:11Z",
"numComments": 0,
"userCount": 0,
"stats": {
"24h": [
[
1546437600,
0
]
]
},
"culprit": "sentry.tasks.reports.deliver_organization_user_report",
"title": "NotFound desc = GetRepoPath: not a git repository",
"id": "13",
"assignedTo": null,
"logger": null,
"type": "error",
"annotations": [],
"metadata": {
"type": "gaierror",
"value": "GetRepoPath: not a git repository"
},
"status": "unresolved",
"subscriptionDetails": null,
"isPublic": false,
"hasSeen": false,
"shortId": "INTERNAL-4",
"shareId": null,
"firstSeen": "2018-12-17T12:00:14Z",
"count": "17283712",
"permalink": "35.228.54.90/sentry/internal/issues/13/",
"level": "error",
"isSubscribed": true,
"isBookmarked": false,
"project": {
"slug": "internal",
"id": "1",
"name": "Internal"
},
"statusDetails": {}
}
]
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mount } from '@vue/test-utils';
import { GlDropdown } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector.vue';
import boardsStore from '~/boards/stores/boards_store';
......@@ -18,17 +19,23 @@ function boardGenerator(n) {
}
describe('BoardsSelector', () => {
let vm;
let wrapper;
let allBoardsResponse;
let recentBoardsResponse;
let fillSearchBox;
const boards = boardGenerator(20);
const recentBoards = boardGenerator(5);
beforeEach(done => {
setFixtures('<div class="js-boards-selector"></div>');
window.gl = window.gl || {};
const fillSearchBox = filterTerm => {
const searchBox = wrapper.find({ ref: 'searchBox' });
const searchBoxInput = searchBox.find('input');
searchBoxInput.setValue(filterTerm);
searchBoxInput.trigger('input');
};
const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
const getDropdownHeaders = () => wrapper.findAll('.dropdown-bold-header');
beforeEach(() => {
boardsStore.setEndpoints({
boardsEndpoint: '',
recentBoardsEndpoint: '',
......@@ -44,13 +51,12 @@ describe('BoardsSelector', () => {
data: recentBoards,
});
spyOn(boardsStore, 'allBoards').and.returnValue(allBoardsResponse);
spyOn(boardsStore, 'recentBoards').and.returnValue(recentBoardsResponse);
boardsStore.allBoards = jest.fn(() => allBoardsResponse);
boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
const Component = Vue.extend(BoardsSelector);
vm = mountComponent(
Component,
{
wrapper = mount(Component, {
propsData: {
throttleDuration,
currentBoard: {
id: 1,
......@@ -71,133 +77,79 @@ describe('BoardsSelector', () => {
scopedIssueBoardFeatureEnabled: true,
weights: [],
},
document.querySelector('.js-boards-selector'),
);
attachToDocument: true,
});
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
vm.$children[0].$emit('show');
Promise.all([allBoardsResponse, recentBoardsResponse])
.then(() => vm.$nextTick())
.then(done)
.catch(done.fail);
fillSearchBox = filterTerm => {
const { searchBox } = vm.$refs;
const searchBoxInput = searchBox.$el.querySelector('input');
searchBoxInput.value = filterTerm;
searchBoxInput.dispatchEvent(new Event('input'));
};
wrapper.find(GlDropdown).vm.$emit('show');
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => Vue.nextTick());
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
wrapper = null;
});
describe('filtering', () => {
it('shows all boards without filtering', done => {
vm.$nextTick()
.then(() => {
const dropdownItem = vm.$el.querySelectorAll('.js-dropdown-item');
expect(dropdownItem.length).toBe(boards.length + recentBoards.length);
})
.then(done)
.catch(done.fail);
it('shows all boards without filtering', () => {
expect(getDropdownItems().length).toBe(boards.length + recentBoards.length);
});
it('shows only matching boards when filtering', done => {
it('shows only matching boards when filtering', () => {
const filterTerm = 'board1';
const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
fillSearchBox(filterTerm);
vm.$nextTick()
.then(() => {
const dropdownItems = vm.$el.querySelectorAll('.js-dropdown-item');
expect(dropdownItems.length).toBe(expectedCount);
})
.then(done)
.catch(done.fail);
return Vue.nextTick().then(() => {
expect(getDropdownItems().length).toBe(expectedCount);
});
});
it('shows message if there are no matching boards', done => {
it('shows message if there are no matching boards', () => {
fillSearchBox('does not exist');
vm.$nextTick()
.then(() => {
const dropdownItems = vm.$el.querySelectorAll('.js-dropdown-item');
expect(dropdownItems.length).toBe(0);
expect(vm.$el).toContainText('No matching boards found');
})
.then(done)
.catch(done.fail);
return Vue.nextTick().then(() => {
expect(getDropdownItems().length).toBe(0);
expect(wrapper.text().includes('No matching boards found')).toBe(true);
});
});
});
describe('recent boards section', () => {
it('shows only when boards are greater than 10', done => {
vm.$nextTick()
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header');
const expectedCount = 2; // Recent + All
it('shows only when boards are greater than 10', () => {
const expectedCount = 2; // Recent + All
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
expect(getDropdownHeaders().length).toBe(expectedCount);
});
it('does not show when boards are less than 10', done => {
spyOn(vm, 'initScrollFade');
spyOn(vm, 'setScrollFade');
vm.$nextTick()
.then(() => {
vm.boards = vm.boards.slice(0, 5);
})
.then(vm.$nextTick)
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header');
const expectedCount = 0;
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
it('does not show when boards are less than 10', () => {
wrapper.setData({
boards: boards.slice(0, 5),
});
return Vue.nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0);
});
});
it('does not show when recentBoards api returns empty array', done => {
vm.$nextTick()
.then(() => {
vm.recentBoards = [];
})
.then(vm.$nextTick)
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header');
const expectedCount = 0;
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
it('does not show when recentBoards api returns empty array', () => {
wrapper.setData({
recentBoards: [],
});
return Vue.nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0);
});
});
it('does not show when search is active', done => {
it('does not show when search is active', () => {
fillSearchBox('Random string');
vm.$nextTick()
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header');
const expectedCount = 0;
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
return Vue.nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0);
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { __ } from '~/locale';
import { GlLoadingIcon, GlLink, GlBadge, GlFormInput, GlAlert, GlSprintf } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import {
GlButton,
GlLoadingIcon,
GlLink,
GlBadge,
GlFormInput,
GlAlert,
GlSprintf,
} from '@gitlab/ui';
import Stacktrace from '~/error_tracking/components/stacktrace.vue';
import ErrorDetails from '~/error_tracking/components/error_details.vue';
import {
......@@ -28,7 +35,7 @@ describe('ErrorDetails', () => {
function mountComponent() {
wrapper = shallowMount(ErrorDetails, {
stubs: { LoadingButton, GlSprintf },
stubs: { GlButton, GlSprintf },
localVue,
store,
mocks,
......@@ -127,7 +134,7 @@ describe('ErrorDetails', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.find(Stacktrace).exists()).toBe(false);
expect(wrapper.find(GlBadge).exists()).toBe(false);
expect(wrapper.findAll('button').length).toBe(3);
expect(wrapper.findAll(GlButton).length).toBe(3);
});
describe('Badges', () => {
......@@ -226,7 +233,7 @@ describe('ErrorDetails', () => {
it('should submit the form', () => {
window.HTMLFormElement.prototype.submit = () => {};
const submitSpy = jest.spyOn(wrapper.vm.$refs.sentryIssueForm, 'submit');
wrapper.find('[data-qa-selector="create_issue_button"]').trigger('click');
wrapper.find('[data-qa-selector="create_issue_button"]').vm.$emit('click');
expect(submitSpy).toHaveBeenCalled();
submitSpy.mockRestore();
});
......@@ -255,14 +262,14 @@ describe('ErrorDetails', () => {
});
it('marks error as ignored when ignore button is clicked', () => {
findUpdateIgnoreStatusButton().trigger('click');
findUpdateIgnoreStatusButton().vm.$emit('click');
expect(actions.updateIgnoreStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.IGNORED }),
);
});
it('marks error as resolved when resolve button is clicked', () => {
findUpdateResolveStatusButton().trigger('click');
findUpdateResolveStatusButton().vm.$emit('click');
expect(actions.updateResolveStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.RESOLVED }),
);
......@@ -281,14 +288,14 @@ describe('ErrorDetails', () => {
});
it('marks error as unresolved when ignore button is clicked', () => {
findUpdateIgnoreStatusButton().trigger('click');
findUpdateIgnoreStatusButton().vm.$emit('click');
expect(actions.updateIgnoreStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.UNRESOLVED }),
);
});
it('marks error as resolved when resolve button is clicked', () => {
findUpdateResolveStatusButton().trigger('click');
findUpdateResolveStatusButton().vm.$emit('click');
expect(actions.updateResolveStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.RESOLVED }),
);
......@@ -307,14 +314,14 @@ describe('ErrorDetails', () => {
});
it('marks error as ignored when ignore button is clicked', () => {
findUpdateIgnoreStatusButton().trigger('click');
findUpdateIgnoreStatusButton().vm.$emit('click');
expect(actions.updateIgnoreStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.IGNORED }),
);
});
it('marks error as unresolved when unresolve button is clicked', () => {
findUpdateResolveStatusButton().trigger('click');
findUpdateResolveStatusButton().vm.$emit('click');
expect(actions.updateResolveStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.UNRESOLVED }),
);
......
......@@ -42,9 +42,6 @@ describe('ErrorTrackingList', () => {
...stubChildren(ErrorTrackingList),
...stubs,
},
data() {
return { errorSearchQuery: 'search' };
},
});
}
......@@ -164,8 +161,9 @@ describe('ErrorTrackingList', () => {
});
it('it searches by query', () => {
findSearchBox().vm.$emit('input', 'search');
findSearchBox().trigger('keyup.enter');
expect(actions.searchByQuery.mock.calls[0][1]).toEqual(wrapper.vm.errorSearchQuery);
expect(actions.searchByQuery.mock.calls[0][1]).toBe('search');
});
it('it sorts by fields', () => {
......
......@@ -72,7 +72,7 @@ exports[`Dashboard template matches the default snapshot 1`] = `
</gl-form-group-stub>
<gl-form-group-stub
class="col-sm-6 col-md-6 col-lg-4"
class="col-sm-auto col-md-auto col-lg-auto"
label="Show last"
label-for="monitor-time-window-dropdown"
label-size="sm"
......@@ -83,6 +83,21 @@ exports[`Dashboard template matches the default snapshot 1`] = `
/>
</gl-form-group-stub>
<gl-form-group-stub
class="col-sm-2 col-md-2 col-lg-1 refresh-dashboard-button"
>
<gl-button-stub
size="md"
title="Reload this page"
variant="default"
>
<icon-stub
name="repeat"
size="16"
/>
</gl-button-stub>
</gl-form-group-stub>
<!---->
</div>
</div>
......
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getYAxisOptions, getTooltipFormatter } from '~/monitoring/components/charts/options';
describe('options spec', () => {
describe('getYAxisOptions', () => {
it('default options', () => {
const options = getYAxisOptions();
expect(options).toMatchObject({
name: expect.any(String),
axisLabel: {
formatter: expect.any(Function),
},
scale: true,
boundaryGap: [expect.any(Number), expect.any(Number)],
});
expect(options.name).not.toHaveLength(0);
});
it('name options', () => {
const yAxisName = 'My axis values';
const options = getYAxisOptions({
name: yAxisName,
});
expect(options).toMatchObject({
name: yAxisName,
nameLocation: 'center',
nameGap: expect.any(Number),
});
});
it('formatter options', () => {
const options = getYAxisOptions({
format: SUPPORTED_FORMATS.bytes,
});
expect(options.axisLabel.formatter).toEqual(expect.any(Function));
expect(options.axisLabel.formatter(1)).toBe('1.00B');
});
});
describe('getTooltipFormatter', () => {
it('default format', () => {
const formatter = getTooltipFormatter();
expect(formatter).toEqual(expect.any(Function));
expect(formatter(1)).toBe('1.000');
});
it('defined format', () => {
const formatter = getTooltipFormatter({
format: SUPPORTED_FORMATS.bytes,
});
expect(formatter(1)).toBe('1.000B');
});
});
});
......@@ -190,7 +190,8 @@ describe('Time series component', () => {
it('formats tooltip content', () => {
const name = 'Total';
const value = '5.556';
const value = '5.556MB';
const dataIndex = 0;
const seriesLabel = timeSeriesChart.find(GlChartSeriesLabel);
......@@ -348,9 +349,9 @@ describe('Time series component', () => {
});
});
it('additional y axis data', () => {
it('additional y-axis data', () => {
const mockCustomYAxisOption = {
name: 'Custom y axis label',
name: 'Custom y-axis label',
axisLabel: {
formatter: jest.fn(),
},
......@@ -397,8 +398,8 @@ describe('Time series component', () => {
deploymentFormatter = getChartOptions().yAxis[1].axisLabel.formatter;
});
it('rounds to 3 decimal places', () => {
expect(dataFormatter(0.88888)).toBe('0.889');
it('formats and rounds to 2 decimal places', () => {
expect(dataFormatter(0.88888)).toBe('0.89MB');
});
it('deployment formatter is set as is required to display a tooltip', () => {
......@@ -421,7 +422,7 @@ describe('Time series component', () => {
});
describe('yAxisLabel', () => {
it('y axis is configured correctly', () => {
it('y-axis is configured correctly', () => {
const { yAxis } = getChartOptions();
expect(yAxis).toHaveLength(2);
......
......@@ -214,6 +214,19 @@ describe('Dashboard', () => {
});
});
it('renders the refresh dashboard button', () => {
createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
setupComponentStore(wrapper);
return wrapper.vm.$nextTick().then(() => {
const refreshBtn = wrapper.findAll({ ref: 'refreshDashboardBtn' });
expect(refreshBtn).toHaveLength(1);
expect(refreshBtn.is(GlButton)).toBe(true);
});
});
describe('when one of the metrics is missing', () => {
beforeEach(() => {
createShallowWrapper({ hasMetrics: true });
......
......@@ -393,13 +393,16 @@ export const metricsDashboardPayload = {
type: 'area-chart',
y_label: 'Total Memory Used',
weight: 4,
y_axis: {
format: 'megabytes',
},
metrics: [
{
id: 'system_metrics_kubernetes_container_memory_total',
query_range:
'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024',
'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1000/1000',
label: 'Total',
unit: 'GB',
unit: 'MB',
metric_id: 12,
prometheus_endpoint_path: 'http://test',
},
......
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import {
uniqMetricsId,
parseEnvironmentsResponse,
......@@ -44,6 +45,11 @@ describe('mapToDashboardViewModel', () => {
title: 'Title A',
type: 'chart-type',
y_label: 'Y Label A',
yAxis: {
name: 'Y Label A',
format: 'number',
precision: 2,
},
metrics: [],
},
],
......@@ -90,6 +96,98 @@ describe('mapToDashboardViewModel', () => {
});
});
describe('panel mapping', () => {
const panelTitle = 'Panel Title';
const yAxisName = 'Y Axis Name';
let dashboard;
const setupWithPanel = panel => {
dashboard = {
panel_groups: [
{
panels: [panel],
},
],
};
};
const getMappedPanel = () => mapToDashboardViewModel(dashboard).panelGroups[0].panels[0];
it('group y_axis defaults', () => {
setupWithPanel({
title: panelTitle,
});
expect(getMappedPanel()).toEqual({
title: panelTitle,
y_label: '',
yAxis: {
name: '',
format: SUPPORTED_FORMATS.number,
precision: 2,
},
metrics: [],
});
});
it('panel with y_axis.name', () => {
setupWithPanel({
y_axis: {
name: yAxisName,
},
});
expect(getMappedPanel().y_label).toBe(yAxisName);
expect(getMappedPanel().yAxis.name).toBe(yAxisName);
});
it('panel with y_axis.name and y_label, displays y_axis.name', () => {
setupWithPanel({
y_label: 'Ignored Y Label',
y_axis: {
name: yAxisName,
},
});
expect(getMappedPanel().y_label).toBe(yAxisName);
expect(getMappedPanel().yAxis.name).toBe(yAxisName);
});
it('group y_label', () => {
setupWithPanel({
y_label: yAxisName,
});
expect(getMappedPanel().y_label).toBe(yAxisName);
expect(getMappedPanel().yAxis.name).toBe(yAxisName);
});
it('group y_axis format and precision', () => {
setupWithPanel({
title: panelTitle,
y_axis: {
precision: 0,
format: SUPPORTED_FORMATS.bytes,
},
});
expect(getMappedPanel().yAxis.format).toBe(SUPPORTED_FORMATS.bytes);
expect(getMappedPanel().yAxis.precision).toBe(0);
});
it('group y_axis unsupported format defaults to number', () => {
setupWithPanel({
title: panelTitle,
y_axis: {
format: 'invalid_format',
},
});
expect(getMappedPanel().yAxis.format).toBe(SUPPORTED_FORMATS.number);
});
});
describe('metrics mapping', () => {
const defaultLabel = 'Panel Label';
const dashboardWithMetric = (metric, label = defaultLabel) => ({
......
......@@ -49,7 +49,7 @@ describe Sentry::Client::Issue do
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
it_behaves_like 'issues have correct length', 2
shared_examples 'has correct external_url' do
context 'external_url' do
......@@ -184,7 +184,7 @@ describe Sentry::Client::Issue do
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
it_behaves_like 'issues have correct length', 2
end
context 'when cursor is present' do
......@@ -194,7 +194,7 @@ describe Sentry::Client::Issue do
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
it_behaves_like 'issues have correct length', 2
end
end
......
......@@ -102,7 +102,7 @@ describe Clusters::Applications::Ingress do
it 'is initialized with ingress arguments' do
expect(subject.name).to eq('ingress')
expect(subject.chart).to eq('stable/nginx-ingress')
expect(subject.version).to eq('1.29.3')
expect(subject.version).to eq('1.29.7')
expect(subject).to be_rbac
expect(subject.files).to eq(ingress.files)
end
......@@ -119,7 +119,7 @@ describe Clusters::Applications::Ingress do
let(:ingress) { create(:clusters_applications_ingress, :errored, version: 'nginx') }
it 'is initialized with the locked version' do
expect(subject.version).to eq('1.29.3')
expect(subject.version).to eq('1.29.7')
end
end
end
......@@ -135,6 +135,7 @@ describe Clusters::Applications::Ingress do
expect(values).to include('repository')
expect(values).to include('stats')
expect(values).to include('podAnnotations')
expect(values).to include('clusterIP')
end
end
......
# frozen_string_literal: true
require 'spec_helper'
describe UserDetail do
it { is_expected.to belong_to(:user) }
describe 'validations' do
describe 'job_title' do
it { is_expected.to validate_presence_of(:job_title) }
it { is_expected.to validate_length_of(:job_title).is_at_most(200) }
end
end
end
......@@ -29,6 +29,7 @@ describe User, :do_not_mock_admin_mode do
it { is_expected.to have_one(:namespace) }
it { is_expected.to have_one(:status) }
it { is_expected.to have_one(:max_access_level_membership) }
it { is_expected.to have_one(:user_detail) }
it { is_expected.to have_many(:snippets).dependent(:destroy) }
it { is_expected.to have_many(:members) }
it { is_expected.to have_many(:project_members) }
......@@ -4318,4 +4319,19 @@ describe User, :do_not_mock_admin_mode do
expect(user.hook_attrs).to eq(user_attributes)
end
end
describe 'user detail' do
context 'when user is initialized' do
let(:user) { build(:user) }
it { expect(user.user_detail).to be_present }
it { expect(user.user_detail).not_to be_persisted }
end
context 'when user detail exists' do
let(:user) { create(:user, job_title: 'Engineer') }
it { expect(user.user_detail).to be_persisted }
end
end
end
......@@ -330,6 +330,21 @@ describe API::Users, :do_not_mock_admin_mode do
expect(json_response.keys).not_to include 'last_sign_in_ip'
end
context 'when job title is present' do
let(:job_title) { 'Fullstack Engineer' }
before do
create(:user_detail, user: user, job_title: job_title)
end
it 'returns job title of a user' do
get api("/users/#{user.id}", user)
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response['job_title']).to eq(job_title)
end
end
context 'when authenticated as admin' do
it 'includes the `is_admin` field' do
get api("/users/#{user.id}", admin)
......
......@@ -86,7 +86,7 @@ describe Clusters::UpdateService do
it 'rejects changes' do
is_expected.to eq(false)
expect(cluster.errors.full_messages).to include('cannot modify during creation')
expect(cluster.errors.full_messages).to include('Cannot modify provider during creation')
end
end
end
......
......@@ -64,6 +64,13 @@ describe Users::UpdateService do
end.not_to change { user.name }
end
it 'updates user detail with provided attributes' do
result = update_user(user, job_title: 'Backend Engineer')
expect(result).to eq(status: :success)
expect(user.job_title).to eq('Backend Engineer')
end
def update_user(user, opts)
described_class.new(user, opts.merge(user: user)).execute
end
......
......@@ -109,7 +109,7 @@ RSpec.shared_examples 'time tracking endpoints' do |issuable_name|
end
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['time_spent'].first).to match(/exceeds the total time spent/)
expect(json_response['message']['base'].first).to eq(_('Time to subtract exceeds the total time spent'))
end
end
end
......
......@@ -6,3 +6,8 @@ controller:
podAnnotations:
prometheus.io/scrape: "true"
prometheus.io/port: "10254"
service:
clusterIP: "-"
defaultBackend:
service:
clusterIP: "-"
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