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 { ...@@ -15,7 +15,6 @@ import {
GlDropdownDivider, GlDropdownDivider,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { __, sprintf, n__ } from '~/locale'; import { __, sprintf, n__ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import Stacktrace from './stacktrace.vue'; import Stacktrace from './stacktrace.vue';
...@@ -28,7 +27,6 @@ import query from '../queries/details.query.graphql'; ...@@ -28,7 +27,6 @@ import query from '../queries/details.query.graphql';
export default { export default {
components: { components: {
LoadingButton,
GlButton, GlButton,
GlFormInput, GlFormInput,
GlLink, GlLink,
...@@ -234,19 +232,21 @@ export default { ...@@ -234,19 +232,21 @@ export default {
</div> </div>
<div class="error-details-actions"> <div class="error-details-actions">
<div class="d-inline-flex bv-d-sm-down-none"> <div class="d-inline-flex bv-d-sm-down-none">
<loading-button <gl-button
:label="ignoreBtnLabel"
:loading="updatingIgnoreStatus" :loading="updatingIgnoreStatus"
data-qa-selector="update_ignore_status_button" data-qa-selector="update_ignore_status_button"
@click="onIgnoreStatusUpdate" @click="onIgnoreStatusUpdate"
/> >
<loading-button {{ ignoreBtnLabel }}
</gl-button>
<gl-button
class="btn-outline-info ml-2" class="btn-outline-info ml-2"
:label="resolveBtnLabel"
:loading="updatingResolveStatus" :loading="updatingResolveStatus"
data-qa-selector="update_resolve_status_button" data-qa-selector="update_resolve_status_button"
@click="onResolveStatusUpdate" @click="onResolveStatusUpdate"
/> >
{{ resolveBtnLabel }}
</gl-button>
<gl-button <gl-button
v-if="error.gitlabIssuePath" v-if="error.gitlabIssuePath"
class="ml-2" class="ml-2"
...@@ -270,14 +270,15 @@ export default { ...@@ -270,14 +270,15 @@ export default {
name="issue[sentry_issue_attributes][sentry_issue_identifier]" name="issue[sentry_issue_attributes][sentry_issue_identifier]"
/> />
<gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" /> <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" />
<loading-button <gl-button
v-if="!error.gitlabIssuePath" v-if="!error.gitlabIssuePath"
class="btn-success" class="btn-success"
:label="__('Create issue')"
:loading="issueCreationInProgress" :loading="issueCreationInProgress"
data-qa-selector="create_issue_button" data-qa-selector="create_issue_button"
@click="createIssue" @click="createIssue"
/> >
{{ __('Create issue') }}
</gl-button>
</form> </form>
</div> </div>
<gl-dropdown <gl-dropdown
......
...@@ -236,6 +236,7 @@ export default { ...@@ -236,6 +236,7 @@ export default {
</gl-dropdown> </gl-dropdown>
<div class="filtered-search-input-container flex-fill"> <div class="filtered-search-input-container flex-fill">
<gl-form-input <gl-form-input
v-model="errorSearchQuery"
class="pl-2 filtered-search" class="pl-2 filtered-search"
:disabled="loading" :disabled="loading"
:placeholder="__('Search or filter results…')" :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/ ...@@ -4,7 +4,6 @@ import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { getFormatter } from '~/lib/utils/unit_format';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { import {
...@@ -16,6 +15,7 @@ import { ...@@ -16,6 +15,7 @@ import {
dateFormats, dateFormats,
chartColorValues, chartColorValues,
} from '../../constants'; } from '../../constants';
import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options';
import { makeDataSeries } from '~/helpers/monitor_helper'; import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils'; import { graphDataValidatorForValues } from '../../utils';
...@@ -30,15 +30,13 @@ const deploymentYAxisCoords = { ...@@ -30,15 +30,13 @@ const deploymentYAxisCoords = {
max: 100, max: 100,
}; };
const THROTTLED_DATAZOOM_WAIT = 1000; // miliseconds const THROTTLED_DATAZOOM_WAIT = 1000; // milliseconds
const timestampToISODate = timestamp => new Date(timestamp).toISOString(); const timestampToISODate = timestamp => new Date(timestamp).toISOString();
const events = { const events = {
datazoom: 'datazoom', datazoom: 'datazoom',
}; };
const yValFormatter = getFormatter('number');
export default { export default {
components: { components: {
GlAreaChart, GlAreaChart,
...@@ -167,14 +165,7 @@ export default { ...@@ -167,14 +165,7 @@ export default {
const option = omit(this.option, ['series', 'yAxis', 'xAxis']); const option = omit(this.option, ['series', 'yAxis', 'xAxis']);
const dataYAxis = { const dataYAxis = {
name: this.yAxisLabel, ...getYAxisOptions(this.graphData.yAxis),
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),
},
...yAxis, ...yAxis,
}; };
...@@ -204,6 +195,7 @@ export default { ...@@ -204,6 +195,7 @@ export default {
series: this.chartOptionSeries, series: this.chartOptionSeries,
xAxis: timeXAxis, xAxis: timeXAxis,
yAxis: [dataYAxis, deploymentsYAxis], yAxis: [dataYAxis, deploymentsYAxis],
grid: getChartGrid(),
dataZoom: [this.dataZoomConfig], dataZoom: [this.dataZoomConfig],
...option, ...option,
}; };
...@@ -282,8 +274,9 @@ export default { ...@@ -282,8 +274,9 @@ export default {
}, },
}; };
}, },
yAxisLabel() { tooltipYFormatter() {
return `${this.graphData.y_label}`; // Use same format as y-axis
return getTooltipFormatter({ format: this.graphData.yAxis?.format });
}, },
}, },
created() { created() {
...@@ -315,12 +308,11 @@ export default { ...@@ -315,12 +308,11 @@ export default {
this.tooltip.commitUrl = deploy.commitUrl; this.tooltip.commitUrl = deploy.commitUrl;
} else { } else {
const { seriesName, color, dataIndex } = dataPoint; const { seriesName, color, dataIndex } = dataPoint;
const value = yValFormatter(yVal, 3);
this.tooltip.content.push({ this.tooltip.content.push({
name: seriesName, name: seriesName,
dataIndex, dataIndex,
value, value: this.tooltipYFormatter(yVal),
color, color,
}); });
} }
......
...@@ -19,7 +19,7 @@ import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; ...@@ -19,7 +19,7 @@ import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import createFlash from '~/flash'; import createFlash from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; 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 invalidUrl from '~/lib/utils/invalid_url';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
...@@ -351,6 +351,10 @@ export default { ...@@ -351,6 +351,10 @@ export default {
}; };
redirectTo(mergeUrlParams(params, window.location.href)); redirectTo(mergeUrlParams(params, window.location.href));
}, },
refreshDashboard() {
refreshCurrentPage();
},
}, },
addMetric: { addMetric: {
title: s__('Metrics|Add metric'), title: s__('Metrics|Add metric'),
...@@ -438,7 +442,7 @@ export default { ...@@ -438,7 +442,7 @@ export default {
:label="s__('Metrics|Show last')" :label="s__('Metrics|Show last')"
label-size="sm" label-size="sm"
label-for="monitor-time-window-dropdown" 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 <date-time-picker
ref="dateTimePicker" ref="dateTimePicker"
...@@ -449,6 +453,18 @@ export default { ...@@ -449,6 +453,18 @@ export default {
/> />
</gl-form-group> </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 <gl-form-group
v-if="hasHeaderButtons" v-if="hasHeaderButtons"
label-for="prometheus-graphs-dropdown-buttons" label-for="prometheus-graphs-dropdown-buttons"
......
import { slugify } from '~/lib/utils/text_utility'; import { slugify } from '~/lib/utils/text_utility';
import createGqClient, { fetchPolicies } from '~/lib/graphql'; import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export const gqClient = createGqClient( export const gqClient = createGqClient(
...@@ -74,18 +75,38 @@ const mapToMetricsViewModel = (metrics, defaultLabel) => ...@@ -74,18 +75,38 @@ const mapToMetricsViewModel = (metrics, defaultLabel) =>
...metric, ...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 * Maps a metrics panel to its view model
* *
* @param {Object} panel - Metrics panel * @param {Object} panel - Metrics panel
* @returns {Object} * @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 { return {
title, title,
type, type,
y_label, y_label: yAxis.name, // Changing y_label to yLabel is pending https://gitlab.com/gitlab-org/gitlab/issues/207198
metrics: mapToMetricsViewModel(metrics, y_label), yAxis,
metrics: mapToMetricsViewModel(metrics, yAxis.name),
}; };
}; };
......
import _ from 'underscore'; import { throttle } from 'lodash';
import $ from 'jquery'; import $ from 'jquery';
import { Terminal } from 'xterm'; import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit'; import * as fit from 'xterm/lib/addons/fit/fit';
...@@ -85,7 +85,7 @@ export default class GLTerminal { ...@@ -85,7 +85,7 @@ export default class GLTerminal {
addScrollListener(onScrollLimit) { addScrollListener(onScrollLimit) {
const viewport = this.container.querySelector('.xterm-viewport'); const viewport = this.container.querySelector('.xterm-viewport');
const listener = _.throttle(() => { const listener = throttle(() => {
onScrollLimit({ onScrollLimit({
canScrollUp: canScrollUp(viewport, SCROLL_MARGIN), canScrollUp: canScrollUp(viewport, SCROLL_MARGIN),
canScrollDown: canScrollDown(viewport, SCROLL_MARGIN), canScrollDown: canScrollDown(viewport, SCROLL_MARGIN),
......
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore'; import { template as lodashTemplate, omit } from 'lodash';
import importU2FLibrary from './util'; import importU2FLibrary from './util';
import U2FError from './error'; import U2FError from './error';
...@@ -37,7 +37,7 @@ export default class U2FAuthenticate { ...@@ -37,7 +37,7 @@ export default class U2FAuthenticate {
// Note: The server library fixes this behaviour in (unreleased) version 1.0.0. // Note: The server library fixes this behaviour in (unreleased) version 1.0.0.
// This can be removed once we upgrade. // This can be removed once we upgrade.
// https://github.com/castle/ruby-u2f/commit/103f428071a81cd3d5f80c2e77d522d5029946a4 // 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 = { this.templates = {
setup: '#js-authenticate-u2f-setup', setup: '#js-authenticate-u2f-setup',
...@@ -74,7 +74,7 @@ export default class U2FAuthenticate { ...@@ -74,7 +74,7 @@ export default class U2FAuthenticate {
renderTemplate(name, params) { renderTemplate(name, params) {
const templateString = $(this.templates[name]).html(); const templateString = $(this.templates[name]).html();
const template = _.template(templateString); const template = lodashTemplate(templateString);
return this.container.html(template(params)); return this.container.html(template(params));
} }
......
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore'; import { template as lodashTemplate } from 'lodash';
import importU2FLibrary from './util'; import importU2FLibrary from './util';
import U2FError from './error'; import U2FError from './error';
...@@ -59,7 +59,7 @@ export default class U2FRegister { ...@@ -59,7 +59,7 @@ export default class U2FRegister {
renderTemplate(name, params) { renderTemplate(name, params) {
const templateString = $(this.templates[name]).html(); const templateString = $(this.templates[name]).html();
const template = _.template(templateString); const template = lodashTemplate(templateString);
return this.container.html(template(params)); return this.container.html(template(params));
} }
......
...@@ -53,6 +53,7 @@ export default { ...@@ -53,6 +53,7 @@ export default {
.then(res => res.data) .then(res => res.data)
.then(data => { .then(data => {
eventHub.$emit('UpdateWidgetData', data); eventHub.$emit('UpdateWidgetData', data);
eventHub.$emit('MRWidgetUpdateRequested');
}) })
.catch(() => { .catch(() => {
this.isCancellingAutoMerge = false; this.isCancellingAutoMerge = false;
......
...@@ -123,13 +123,15 @@ export default class MergeRequestStore { ...@@ -123,13 +123,15 @@ export default class MergeRequestStore {
const currentUser = data.current_user; const currentUser = data.current_user;
this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path; if (currentUser) {
this.revertInForkPath = currentUser.revert_in_fork_path; 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.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; this.canCreateIssue = currentUser.can_create_issue || false;
this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || 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); this.setState(data);
} }
......
...@@ -98,6 +98,14 @@ ...@@ -98,6 +98,14 @@
} }
} }
.refresh-dashboard-button {
margin-top: 22px;
@media(max-width: map-get($grid-breakpoints, sm)) {
margin-top: 0;
}
}
.metric-area { .metric-area {
opacity: 0.25; opacity: 0.25;
} }
......
...@@ -117,6 +117,7 @@ class ProfilesController < Profiles::ApplicationController ...@@ -117,6 +117,7 @@ class ProfilesController < Profiles::ApplicationController
:private_profile, :private_profile,
:include_private_contributions, :include_private_contributions,
:timezone, :timezone,
:job_title,
status: [:emoji, :message] status: [:emoji, :message]
) )
end end
......
...@@ -66,7 +66,7 @@ module Projects ...@@ -66,7 +66,7 @@ module Projects
[ [
:runners_token, :builds_enabled, :build_allow_git_fetch, :runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds, :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], auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy],
ci_cd_settings_attributes: [:default_git_depth] ci_cd_settings_attributes: [:default_git_depth]
].tap do |list| ].tap do |list|
......
...@@ -38,7 +38,7 @@ class Appearance < ApplicationRecord ...@@ -38,7 +38,7 @@ class Appearance < ApplicationRecord
def single_appearance_row def single_appearance_row
if self.class.any? 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
end end
......
...@@ -389,7 +389,7 @@ module ApplicationSettingImplementation ...@@ -389,7 +389,7 @@ module ApplicationSettingImplementation
def terms_exist def terms_exist
return unless enforce_terms? 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 end
def expire_performance_bar_allowed_user_ids_cache def expire_performance_bar_allowed_user_ids_cache
......
...@@ -148,7 +148,7 @@ module Ci ...@@ -148,7 +148,7 @@ module Ci
def valid_file_format? def valid_file_format?
unless TYPE_AND_FORMAT_PAIRS[self.file_type&.to_sym] == self.file_format&.to_sym 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
end end
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Clusters module Clusters
module Applications module Applications
class Ingress < ApplicationRecord class Ingress < ApplicationRecord
VERSION = '1.29.3' VERSION = '1.29.7'
MODSECURITY_LOG_CONTAINER_NAME = 'modsecurity-log' MODSECURITY_LOG_CONTAINER_NAME = 'modsecurity-log'
self.table_name = 'clusters_applications_ingress' self.table_name = 'clusters_applications_ingress'
......
...@@ -306,7 +306,7 @@ module Clusters ...@@ -306,7 +306,7 @@ module Clusters
.where.not(id: id) .where.not(id: id)
if duplicate_management_clusters.any? 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
end end
...@@ -380,7 +380,7 @@ module Clusters ...@@ -380,7 +380,7 @@ module Clusters
def restrict_modification def restrict_modification
if provider&.on_creation? if provider&.on_creation?
errors.add(:base, "cannot modify during creation") errors.add(:base, _('Cannot modify provider during creation'))
return false return false
end end
......
...@@ -19,7 +19,7 @@ module HasRepository ...@@ -19,7 +19,7 @@ module HasRepository
def valid_repo? def valid_repo?
repository.exists? repository.exists?
rescue rescue
errors.add(:path, _('Invalid repository path')) errors.add(:base, _('Invalid repository path'))
false false
end end
......
...@@ -37,7 +37,7 @@ module Milestoneable ...@@ -37,7 +37,7 @@ module Milestoneable
private private
def milestone_is_valid 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
end end
......
...@@ -77,7 +77,7 @@ module TimeTrackable ...@@ -77,7 +77,7 @@ module TimeTrackable
return if time_spent.nil? || time_spent == :reset return if time_spent.nil? || time_spent == :reset
if time_spent < 0 && (time_spent.abs > original_total_time_spent) 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
end end
......
...@@ -105,7 +105,7 @@ class DeployToken < ApplicationRecord ...@@ -105,7 +105,7 @@ class DeployToken < ApplicationRecord
end end
def ensure_at_least_one_scope 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 end
def default_username def default_username
......
...@@ -19,7 +19,13 @@ class DescriptionVersion < ApplicationRecord ...@@ -19,7 +19,13 @@ class DescriptionVersion < ApplicationRecord
def exactly_one_issuable def exactly_one_issuable
issuable_count = self.class.issuable_attrs.count { |attr| self["#{attr}_id"] } 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
end end
......
...@@ -78,7 +78,7 @@ class ExternalPullRequest < ApplicationRecord ...@@ -78,7 +78,7 @@ class ExternalPullRequest < ApplicationRecord
def not_from_fork def not_from_fork
if 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
end end
......
...@@ -11,6 +11,6 @@ class MilestoneRelease < ApplicationRecord ...@@ -11,6 +11,6 @@ class MilestoneRelease < ApplicationRecord
def same_project_between_milestone_and_release def same_project_between_milestone_and_release
return if milestone&.project_id == release&.project_id 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
end end
...@@ -376,7 +376,7 @@ class Namespace < ApplicationRecord ...@@ -376,7 +376,7 @@ class Namespace < ApplicationRecord
def nesting_level_allowed def nesting_level_allowed
if ancestors.count > Group::NUMBER_OF_ANCESTORS_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
end end
......
...@@ -31,7 +31,7 @@ class ProjectCiCdSetting < ApplicationRecord ...@@ -31,7 +31,7 @@ class ProjectCiCdSetting < ApplicationRecord
end end
def forward_deployment_enabled? def forward_deployment_enabled?
super && ::Feature.enabled?(:forward_deployment_enabled, project) super && ::Feature.enabled?(:forward_deployment_enabled, project, default_enabled: true)
end end
private private
......
...@@ -168,7 +168,7 @@ class IssueTrackerService < Service ...@@ -168,7 +168,7 @@ class IssueTrackerService < Service
return if project.blank? return if project.blank?
if project.services.external_issue_trackers.where.not(id: id).any? 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 end
end end
......
...@@ -69,13 +69,13 @@ class PrometheusAlert < ApplicationRecord ...@@ -69,13 +69,13 @@ class PrometheusAlert < ApplicationRecord
def require_valid_environment_project! def require_valid_environment_project!
return if project == environment&.project return if project == environment&.project
errors.add(:environment, "invalid project") errors.add(:environment, 'invalid project')
end end
def require_valid_metric_project! def require_valid_metric_project!
return if prometheus_metric&.common? return if prometheus_metric&.common?
return if project == prometheus_metric&.project return if project == prometheus_metric&.project
errors.add(:prometheus_metric, "invalid project") errors.add(:prometheus_metric, 'invalid project')
end end
end end
...@@ -37,6 +37,9 @@ class ResourceEvent < ApplicationRecord ...@@ -37,6 +37,9 @@ class ResourceEvent < ApplicationRecord
return true if issuable_count == 1 return true if issuable_count == 1
end 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
end end
...@@ -111,7 +111,10 @@ class SentNotification < ApplicationRecord ...@@ -111,7 +111,10 @@ class SentNotification < ApplicationRecord
note = create_reply('Test', dryrun: true) note = create_reply('Test', dryrun: true)
unless note.valid? 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
end end
......
...@@ -28,9 +28,9 @@ class Timelog < ApplicationRecord ...@@ -28,9 +28,9 @@ class Timelog < ApplicationRecord
def issuable_id_is_present def issuable_id_is_present
if issue_id && merge_request_id 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? 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
end end
......
...@@ -162,6 +162,7 @@ class User < ApplicationRecord ...@@ -162,6 +162,7 @@ class User < ApplicationRecord
has_one :status, class_name: 'UserStatus' has_one :status, class_name: 'UserStatus'
has_one :user_preference has_one :user_preference
has_one :user_detail
# #
# Validations # Validations
...@@ -259,8 +260,10 @@ class User < ApplicationRecord ...@@ -259,8 +260,10 @@ class User < ApplicationRecord
delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference
delegate :setup_for_company, :setup_for_company=, 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 :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_preference, update_only: true
accepts_nested_attributes_for :user_detail, update_only: true
state_machine :state, initial: :active do state_machine :state, initial: :active do
event :block do event :block do
...@@ -1619,6 +1622,10 @@ class User < ApplicationRecord ...@@ -1619,6 +1622,10 @@ class User < ApplicationRecord
super.presence || build_user_preference super.presence || build_user_preference
end end
def user_detail
super.presence || build_user_detail
end
def todos_limited_to(ids) def todos_limited_to(ids)
todos.where(id: ids) todos.where(id: ids)
end end
......
# frozen_string_literal: true
class UserDetail < ApplicationRecord
belongs_to :user
validates :job_title, presence: true, length: { maximum: 200 }
end
...@@ -88,6 +88,15 @@ ...@@ -88,6 +88,15 @@
= _("New pipelines will cancel older, pending pipelines on the same branch") = _("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' = 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 %hr
.form-group .form-group
= f.label :build_coverage_regex, _("Test coverage parsing"), class: 'label-bold' = 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: ...@@ -20,6 +20,8 @@ en:
token: "Grafana HTTP API Token" token: "Grafana HTTP API Token"
grafana_url: "Grafana API URL" grafana_url: "Grafana API URL"
grafana_enabled: "Grafana integration enabled" grafana_enabled: "Grafana integration enabled"
user/user_detail:
job_title: 'Job title'
views: views:
pagination: pagination:
previous: "Prev" 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 @@ ...@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm" enable_extension "pg_trgm"
...@@ -4170,6 +4170,11 @@ ActiveRecord::Schema.define(version: 2020_02_26_162723) do ...@@ -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 t.index ["user_id", "key"], name: "index_user_custom_attributes_on_user_id_and_key", unique: true
end 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| create_table "user_interacted_projects", id: false, force: :cascade do |t|
t.integer "user_id", null: false t.integer "user_id", null: false
t.integer "project_id", null: false t.integer "project_id", null: false
...@@ -5028,6 +5033,7 @@ ActiveRecord::Schema.define(version: 2020_02_26_162723) do ...@@ -5028,6 +5033,7 @@ ActiveRecord::Schema.define(version: 2020_02_26_162723) do
add_foreign_key "u2f_registrations", "users" add_foreign_key "u2f_registrations", "users"
add_foreign_key "user_callouts", "users", on_delete: :cascade add_foreign_key "user_callouts", "users", on_delete: :cascade
add_foreign_key "user_custom_attributes", "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", "projects", name: "fk_722ceba4f7", on_delete: :cascade
add_foreign_key "user_interacted_projects", "users", name: "fk_0894651f08", 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 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 ...@@ -75,7 +75,7 @@ GitLab stores files and blobs such as Issue attachments or LFS objects into eith
- The filesystem in a specific location. - The filesystem in a specific location.
- An Object Storage solution. Object Storage solutions can be: - An Object Storage solution. Object Storage solutions can be:
- Cloud based like Amazon S3 Google Cloud Storage. - 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. - 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 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 ...@@ -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. 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.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. 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): [GitLab Rails console](https://docs.gitlab.com/omnibus/maintenance/#starting-a-rails-console-session):
```ruby ```ruby
...@@ -113,9 +113,9 @@ text field exceeds this limit then the text will be truncated to this number of ...@@ -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. characters and the rest will not be indexed and hence will not be searchable.
- On GitLab.com this is limited to 20000 characters - 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). Elasticsearch](../integration/elasticsearch.md#enabling-elasticsearch).
NOTE: **Note:** Set the limit to `0` to disable it. 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. ...@@ -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 Libravatar is another service that delivers your avatar (profile picture) to
other websites. The Libravatar API is other websites. The Libravatar API is
[heavily based on gravatar](https://wiki.libravatar.org/api/), so you can [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. server.
## Configuration ## Configuration
...@@ -35,7 +35,7 @@ the configuration options as follows: ...@@ -35,7 +35,7 @@ the configuration options as follows:
ssl_url: "https://seccdn.libravatar.org/avatar/%{hash}?s=%{size}&d=identicon" 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/), 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 the URL will be different in the configuration, but you must provide the same
......
...@@ -95,6 +95,7 @@ GET /users ...@@ -95,6 +95,7 @@ GET /users
"twitter": "", "twitter": "",
"website_url": "", "website_url": "",
"organization": "", "organization": "",
"job_title": "",
"last_sign_in_at": "2012-06-01T11:41:01Z", "last_sign_in_at": "2012-06-01T11:41:01Z",
"confirmed_at": "2012-05-23T09:05:22Z", "confirmed_at": "2012-05-23T09:05:22Z",
"theme_id": 1, "theme_id": 1,
...@@ -132,6 +133,7 @@ GET /users ...@@ -132,6 +133,7 @@ GET /users
"twitter": "", "twitter": "",
"website_url": "", "website_url": "",
"organization": "", "organization": "",
"job_title": "",
"last_sign_in_at": null, "last_sign_in_at": null,
"confirmed_at": "2012-05-30T16:53:06.148Z", "confirmed_at": "2012-05-30T16:53:06.148Z",
"theme_id": 1, "theme_id": 1,
...@@ -247,7 +249,8 @@ Parameters: ...@@ -247,7 +249,8 @@ Parameters:
"linkedin": "", "linkedin": "",
"twitter": "", "twitter": "",
"website_url": "", "website_url": "",
"organization": "" "organization": "",
"job_title": "Operations Specialist"
} }
``` ```
...@@ -282,6 +285,7 @@ Example Responses: ...@@ -282,6 +285,7 @@ Example Responses:
"twitter": "", "twitter": "",
"website_url": "", "website_url": "",
"organization": "", "organization": "",
"job_title": "Operations Specialist",
"last_sign_in_at": "2012-06-01T11:41:01Z", "last_sign_in_at": "2012-06-01T11:41:01Z",
"confirmed_at": "2012-05-23T09:05:22Z", "confirmed_at": "2012-05-23T09:05:22Z",
"theme_id": 1, "theme_id": 1,
...@@ -545,6 +549,7 @@ GET /user ...@@ -545,6 +549,7 @@ GET /user
"twitter": "", "twitter": "",
"website_url": "", "website_url": "",
"organization": "", "organization": "",
"job_title": "",
"last_sign_in_at": "2012-06-01T11:41:01Z", "last_sign_in_at": "2012-06-01T11:41:01Z",
"confirmed_at": "2012-05-23T09:05:22Z", "confirmed_at": "2012-05-23T09:05:22Z",
"theme_id": 1, "theme_id": 1,
......
...@@ -693,7 +693,7 @@ To configure credentials store, follow these steps: ...@@ -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 `${GITLAB_RUNNER_HOME}/.docker/config.json`. GitLab Runner will read this config file
and will use the needed helper for this specific repository. 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 ...@@ -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`. 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 GitLab Runner will read this config file and will use the needed helper for this
specific repository. specific repository.
......
...@@ -44,7 +44,7 @@ Complementary reads: ...@@ -44,7 +44,7 @@ Complementary reads:
- [Guidelines for implementing Enterprise Edition features](ee_features.md) - [Guidelines for implementing Enterprise Edition features](ee_features.md)
- [Danger bot](dangerbot.md) - [Danger bot](dangerbot.md)
- [Generate a changelog entry with `bin/changelog`](changelog.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 ## UX and Frontend guides
......
# Delete existing migrations # Delete existing migrations
When removing existing migrations from the GitLab project, you have to take into account 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: 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. ...@@ -147,7 +147,7 @@ is always on or off to the users.
## Cleaning up ## Cleaning up
Once the change is deemed stable, submit a new merge request to remove the 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 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 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 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 ...@@ -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. 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 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) ### Step 1: Ignoring the column (release M)
......
...@@ -121,7 +121,7 @@ With the [Customers Portal](https://customers.gitlab.com/) you can: ...@@ -121,7 +121,7 @@ With the [Customers Portal](https://customers.gitlab.com/) you can:
To change billing information: 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. Go to the **My Account** page.
1. Make the required changes to the **Account Details** information. 1. Make the required changes to the **Account Details** information.
1. Click **Update Account**. 1. Click **Update Account**.
...@@ -143,7 +143,7 @@ account: ...@@ -143,7 +143,7 @@ account:
1. On the Customers Portal page, click 1. On the Customers Portal page, click
[**My Account**](https://customers.gitlab.com/customers/edit) in the top menu. [**My Account**](https://customers.gitlab.com/customers/edit) in the top menu.
1. Under **Your GitLab.com account**, click **Change linked account** button. 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 ### Change the associated namespace
...@@ -195,9 +195,9 @@ The [Customers Portal](https://customers.gitlab.com/customers/sign_in) is your t ...@@ -195,9 +195,9 @@ The [Customers Portal](https://customers.gitlab.com/customers/sign_in) is your t
TIP: **Tip:** 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. 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. - Stale user accounts can be a security risk. A regular review helps reduce this risk.
#### Users over License #### Users over License
...@@ -219,7 +219,7 @@ Self-managed instances can add users to a subscription any time during the subsc ...@@ -219,7 +219,7 @@ Self-managed instances can add users to a subscription any time during the subsc
To add users to a subscription: 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 **Manage Purchases**.
1. Select **Add more seats**. 1. Select **Add more seats**.
1. Enter the number of additional users. 1. Enter the number of additional users.
...@@ -234,7 +234,7 @@ The following will be emailed to you: ...@@ -234,7 +234,7 @@ The following will be emailed to you:
### Renew or change a GitLab.com subscription ### 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. 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: ...@@ -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. 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:** 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. 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. 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:** 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. 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. 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. ...@@ -294,13 +294,11 @@ CI pipeline minutes are the execution time for your [pipelines](../ci/pipelines.
Quotas apply to: 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**. - 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 > Pipeline quota**. - 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. 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. 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) When the CI minutes are depleted, an email is sent automatically to notify the owner(s)
...@@ -317,10 +315,10 @@ main quota. Additional minutes: ...@@ -317,10 +315,10 @@ main quota. Additional minutes:
To purchase additional minutes for your group on GitLab.com: 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. 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. 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. 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: To purchase additional minutes for your personal namespace:
......
...@@ -191,6 +191,25 @@ you can enable this in the project settings: ...@@ -191,6 +191,25 @@ you can enable this in the project settings:
1. Check the **Auto-cancel redundant, pending pipelines** checkbox. 1. Check the **Auto-cancel redundant, pending pipelines** checkbox.
1. Click **Save changes**. 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 ## Pipeline Badges
In the pipelines settings page you can find pipeline status and test coverage In the pipelines settings page you can find pipeline status and test coverage
......
...@@ -4,7 +4,7 @@ module API ...@@ -4,7 +4,7 @@ module API
module Entities module Entities
class User < UserBasic class User < UserBasic
expose :created_at, if: ->(user, opts) { Ability.allowed?(opts[:current_user], :read_user_profile, user) } 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 end
end end
...@@ -1281,6 +1281,9 @@ msgstr "" ...@@ -1281,6 +1281,9 @@ msgstr ""
msgid "Admin notes" msgid "Admin notes"
msgstr "" msgstr ""
msgid "AdminArea|Included Free in license"
msgstr ""
msgid "AdminArea|Stop all jobs" msgid "AdminArea|Stop all jobs"
msgstr "" msgstr ""
...@@ -2012,6 +2015,9 @@ msgstr "" ...@@ -2012,6 +2015,9 @@ msgstr ""
msgid "Anonymous" msgid "Anonymous"
msgstr "" 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" msgid "Anti-spam verification"
msgstr "" msgstr ""
...@@ -3248,6 +3254,9 @@ msgstr "" ...@@ -3248,6 +3254,9 @@ msgstr ""
msgid "Cannot modify managed Kubernetes cluster" msgid "Cannot modify managed Kubernetes cluster"
msgstr "" msgstr ""
msgid "Cannot modify provider during creation"
msgstr ""
msgid "Cannot refer to a group milestone by an internal id!" msgid "Cannot refer to a group milestone by an internal id!"
msgstr "" msgstr ""
...@@ -5817,6 +5826,9 @@ msgstr "" ...@@ -5817,6 +5826,9 @@ msgstr ""
msgid "Current node" msgid "Current node"
msgstr "" msgstr ""
msgid "Current node must be the primary node or you will be locking yourself out"
msgstr ""
msgid "Current password" msgid "Current password"
msgstr "" msgstr ""
...@@ -8018,6 +8030,9 @@ msgstr "" ...@@ -8018,6 +8030,9 @@ msgstr ""
msgid "Evidence collection" msgid "Evidence collection"
msgstr "" msgstr ""
msgid "Exactly one of %{attributes} is required"
msgstr ""
msgid "Example: @sub\\.company\\.com$" msgid "Example: @sub\\.company\\.com$"
msgstr "" msgstr ""
...@@ -9776,6 +9791,9 @@ msgstr "" ...@@ -9776,6 +9791,9 @@ msgstr ""
msgid "Group pipeline minutes were successfully reset." msgid "Group pipeline minutes were successfully reset."
msgstr "" msgstr ""
msgid "Group requires separate account"
msgstr ""
msgid "Group variables (inherited)" msgid "Group variables (inherited)"
msgstr "" msgstr ""
...@@ -10121,6 +10139,9 @@ 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}" 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 "" msgstr ""
msgid "Hashed Storage must be enabled to use Geo"
msgstr ""
msgid "Hashed repository storage paths" msgid "Hashed repository storage paths"
msgstr "" msgstr ""
...@@ -10755,6 +10776,9 @@ msgstr "" ...@@ -10755,6 +10776,9 @@ msgstr ""
msgid "Invalid field" msgid "Invalid field"
msgstr "" msgstr ""
msgid "Invalid file format with specified file type"
msgstr ""
msgid "Invalid file." msgid "Invalid file."
msgstr "" msgstr ""
...@@ -10848,6 +10872,9 @@ msgstr "" ...@@ -10848,6 +10872,9 @@ msgstr ""
msgid "Issue events" msgid "Issue events"
msgstr "" msgstr ""
msgid "Issue or Merge Request ID is required"
msgstr ""
msgid "Issue template (optional)" msgid "Issue template (optional)"
msgstr "" msgstr ""
...@@ -12387,6 +12414,9 @@ msgstr "" ...@@ -12387,6 +12414,9 @@ msgstr ""
msgid "Metrics|Prometheus Query Documentation" msgid "Metrics|Prometheus Query Documentation"
msgstr "" msgstr ""
msgid "Metrics|Reload this page"
msgstr ""
msgid "Metrics|Show last" msgid "Metrics|Show last"
msgstr "" msgstr ""
...@@ -12432,6 +12462,9 @@ msgstr "" ...@@ -12432,6 +12462,9 @@ msgstr ""
msgid "Metrics|Validating query" msgid "Metrics|Validating query"
msgstr "" msgstr ""
msgid "Metrics|Values"
msgstr ""
msgid "Metrics|View logs" msgid "Metrics|View logs"
msgstr "" msgstr ""
...@@ -13141,6 +13174,9 @@ msgstr "" ...@@ -13141,6 +13174,9 @@ msgstr ""
msgid "Note" msgid "Note"
msgstr "" 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}." 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 "" msgstr ""
...@@ -13374,6 +13410,12 @@ msgstr "" ...@@ -13374,6 +13410,12 @@ msgstr ""
msgid "Only 'Reporter' roles and above on tiers Premium / Silver and above can see Value Stream Analytics." msgid "Only 'Reporter' roles and above on tiers Premium / Silver and above can see Value Stream Analytics."
msgstr "" msgstr ""
msgid "Only 1 appearances row can exist"
msgstr ""
msgid "Only Issue ID or Merge Request ID is required"
msgstr ""
msgid "Only Project Members" msgid "Only Project Members"
msgstr "" msgstr ""
...@@ -13548,12 +13590,24 @@ msgstr "" ...@@ -13548,12 +13590,24 @@ msgstr ""
msgid "Package Registry" msgid "Package Registry"
msgstr "" msgstr ""
msgid "Package already exists"
msgstr ""
msgid "Package deleted successfully" msgid "Package deleted successfully"
msgstr "" msgstr ""
msgid "Package information" msgid "Package information"
msgstr "" 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" msgid "Package was removed"
msgstr "" msgstr ""
...@@ -15753,6 +15807,9 @@ msgstr "" ...@@ -15753,6 +15807,9 @@ msgstr ""
msgid "Pull" msgid "Pull"
msgstr "" 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." 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 "" msgstr ""
...@@ -16015,6 +16072,9 @@ msgid_plural "Releases" ...@@ -16015,6 +16072,9 @@ msgid_plural "Releases"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Release does not have the same project as the milestone"
msgstr ""
msgid "Release notes" msgid "Release notes"
msgstr "" msgstr ""
...@@ -16834,6 +16894,9 @@ msgstr "" ...@@ -16834,6 +16894,9 @@ msgstr ""
msgid "Scopes" msgid "Scopes"
msgstr "" msgstr ""
msgid "Scopes can't be blank"
msgstr ""
msgid "Scroll down" msgid "Scroll down"
msgstr "" msgstr ""
...@@ -17908,6 +17971,9 @@ msgstr "" ...@@ -17908,6 +17971,9 @@ msgstr ""
msgid "Size settings for static websites" msgid "Size settings for static websites"
msgstr "" msgstr ""
msgid "Skip older, pending deployment jobs"
msgstr ""
msgid "Skip this for now" msgid "Skip this for now"
msgstr "" msgstr ""
...@@ -19349,6 +19415,9 @@ 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." 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 "" 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." msgid "The license was removed. GitLab has fallen back on the previous license."
msgstr "" msgstr ""
...@@ -20003,6 +20072,9 @@ 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." 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 "" 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." msgid "This may expose confidential information as the selected fork is in another namespace that can have other members."
msgstr "" msgstr ""
...@@ -20114,6 +20186,9 @@ msgstr "" ...@@ -20114,6 +20186,9 @@ msgstr ""
msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here." msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
msgstr "" msgstr ""
msgid "Thread to reply to cannot be found"
msgstr ""
msgid "Threat Monitoring" msgid "Threat Monitoring"
msgstr "" msgstr ""
...@@ -20210,6 +20285,9 @@ msgstr "" ...@@ -20210,6 +20285,9 @@ msgstr ""
msgid "Time to merge" msgid "Time to merge"
msgstr "" msgstr ""
msgid "Time to subtract exceeds the total time spent"
msgstr ""
msgid "Time tracking" msgid "Time tracking"
msgstr "" msgstr ""
...@@ -21288,6 +21366,9 @@ msgstr "" ...@@ -21288,6 +21366,9 @@ msgstr ""
msgid "User identity was successfully updated." msgid "User identity was successfully updated."
msgstr "" msgstr ""
msgid "User is not allowed to resolve thread"
msgstr ""
msgid "User key was successfully removed." msgid "User key was successfully removed."
msgstr "" msgstr ""
...@@ -22043,6 +22124,9 @@ msgstr "" ...@@ -22043,6 +22124,9 @@ msgstr ""
msgid "What are you searching for?" msgid "What are you searching for?"
msgstr "" 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" msgid "When a runner is locked, it cannot be assigned to other projects"
msgstr "" msgstr ""
...@@ -22600,6 +22684,9 @@ msgstr "" ...@@ -22600,6 +22684,9 @@ msgstr ""
msgid "You need to register a two-factor authentication app before you can set up a U2F device." msgid "You need to register a two-factor authentication app before you can set up a U2F device."
msgstr "" msgstr ""
msgid "You need to set terms to be enforced"
msgstr ""
msgid "You need to specify both an Access Token and a Host URL." msgid "You need to specify both an Access Token and a Host URL."
msgstr "" msgstr ""
......
...@@ -89,6 +89,16 @@ describe ProfilesController, :request_store do ...@@ -89,6 +89,16 @@ describe ProfilesController, :request_store do
expect(user.reload.status.message).to eq('Working hard!') expect(user.reload.status.message).to eq('Working hard!')
expect(response).to have_gitlab_http_status(:found) expect(response).to have_gitlab_http_status(:found)
end 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 end
describe 'PUT update_username' do 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 ...@@ -64,6 +64,10 @@ describe 'Merge request > User merges when pipeline succeeds', :js do
before do before do
click_button "Merge when pipeline succeeds" click_button "Merge when pipeline succeeds"
click_link "Cancel automatic merge" click_link "Cancel automatic merge"
wait_for_requests
expect(page).to have_content 'Merge when pipeline succeeds', wait: 0
end end
it_behaves_like 'Merge when pipeline succeeds activator' it_behaves_like 'Merge when pipeline succeeds activator'
......
...@@ -61,6 +61,28 @@ describe "Projects > Settings > Pipelines settings" do ...@@ -61,6 +61,28 @@ describe "Projects > Settings > Pipelines settings" do
expect(checkbox).to be_checked expect(checkbox).to be_checked
end 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 describe 'Auto DevOps' do
context 'when auto devops is turned on instance-wide' do context 'when auto devops is turned on instance-wide' do
before 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", "lastSeen": "2018-12-31T12:00:11Z",
"numComments": 0, "numComments": 0,
"userCount": 0, "userCount": 0,
...@@ -39,4 +40,47 @@ ...@@ -39,4 +40,47 @@
"name": "Internal" "name": "Internal"
}, },
"statusDetails": {} "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 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 { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector.vue'; import BoardsSelector from '~/boards/components/boards_selector.vue';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
...@@ -18,17 +19,23 @@ function boardGenerator(n) { ...@@ -18,17 +19,23 @@ function boardGenerator(n) {
} }
describe('BoardsSelector', () => { describe('BoardsSelector', () => {
let vm; let wrapper;
let allBoardsResponse; let allBoardsResponse;
let recentBoardsResponse; let recentBoardsResponse;
let fillSearchBox;
const boards = boardGenerator(20); const boards = boardGenerator(20);
const recentBoards = boardGenerator(5); const recentBoards = boardGenerator(5);
beforeEach(done => { const fillSearchBox = filterTerm => {
setFixtures('<div class="js-boards-selector"></div>'); const searchBox = wrapper.find({ ref: 'searchBox' });
window.gl = window.gl || {}; 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({ boardsStore.setEndpoints({
boardsEndpoint: '', boardsEndpoint: '',
recentBoardsEndpoint: '', recentBoardsEndpoint: '',
...@@ -44,13 +51,12 @@ describe('BoardsSelector', () => { ...@@ -44,13 +51,12 @@ describe('BoardsSelector', () => {
data: recentBoards, data: recentBoards,
}); });
spyOn(boardsStore, 'allBoards').and.returnValue(allBoardsResponse); boardsStore.allBoards = jest.fn(() => allBoardsResponse);
spyOn(boardsStore, 'recentBoards').and.returnValue(recentBoardsResponse); boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
const Component = Vue.extend(BoardsSelector); const Component = Vue.extend(BoardsSelector);
vm = mountComponent( wrapper = mount(Component, {
Component, propsData: {
{
throttleDuration, throttleDuration,
currentBoard: { currentBoard: {
id: 1, id: 1,
...@@ -71,133 +77,79 @@ describe('BoardsSelector', () => { ...@@ -71,133 +77,79 @@ describe('BoardsSelector', () => {
scopedIssueBoardFeatureEnabled: true, scopedIssueBoardFeatureEnabled: true,
weights: [], weights: [],
}, },
document.querySelector('.js-boards-selector'), attachToDocument: true,
); });
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
vm.$children[0].$emit('show'); wrapper.find(GlDropdown).vm.$emit('show');
Promise.all([allBoardsResponse, recentBoardsResponse]) return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => Vue.nextTick());
.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'));
};
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('filtering', () => { describe('filtering', () => {
it('shows all boards without filtering', done => { it('shows all boards without filtering', () => {
vm.$nextTick() expect(getDropdownItems().length).toBe(boards.length + recentBoards.length);
.then(() => {
const dropdownItem = vm.$el.querySelectorAll('.js-dropdown-item');
expect(dropdownItem.length).toBe(boards.length + recentBoards.length);
})
.then(done)
.catch(done.fail);
}); });
it('shows only matching boards when filtering', done => { it('shows only matching boards when filtering', () => {
const filterTerm = 'board1'; const filterTerm = 'board1';
const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length; const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
fillSearchBox(filterTerm); fillSearchBox(filterTerm);
vm.$nextTick() return Vue.nextTick().then(() => {
.then(() => { expect(getDropdownItems().length).toBe(expectedCount);
const dropdownItems = vm.$el.querySelectorAll('.js-dropdown-item'); });
expect(dropdownItems.length).toBe(expectedCount);
})
.then(done)
.catch(done.fail);
}); });
it('shows message if there are no matching boards', done => { it('shows message if there are no matching boards', () => {
fillSearchBox('does not exist'); fillSearchBox('does not exist');
vm.$nextTick() return Vue.nextTick().then(() => {
.then(() => { expect(getDropdownItems().length).toBe(0);
const dropdownItems = vm.$el.querySelectorAll('.js-dropdown-item'); expect(wrapper.text().includes('No matching boards found')).toBe(true);
});
expect(dropdownItems.length).toBe(0);
expect(vm.$el).toContainText('No matching boards found');
})
.then(done)
.catch(done.fail);
}); });
}); });
describe('recent boards section', () => { describe('recent boards section', () => {
it('shows only when boards are greater than 10', done => { it('shows only when boards are greater than 10', () => {
vm.$nextTick() const expectedCount = 2; // Recent + All
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header');
const expectedCount = 2; // Recent + All
expect(expectedCount).toBe(headerEls.length); expect(getDropdownHeaders().length).toBe(expectedCount);
})
.then(done)
.catch(done.fail);
}); });
it('does not show when boards are less than 10', done => { it('does not show when boards are less than 10', () => {
spyOn(vm, 'initScrollFade'); wrapper.setData({
spyOn(vm, 'setScrollFade'); boards: boards.slice(0, 5),
});
vm.$nextTick()
.then(() => { return Vue.nextTick().then(() => {
vm.boards = vm.boards.slice(0, 5); expect(getDropdownHeaders().length).toBe(0);
}) });
.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', done => { it('does not show when recentBoards api returns empty array', () => {
vm.$nextTick() wrapper.setData({
.then(() => { recentBoards: [],
vm.recentBoards = []; });
})
.then(vm.$nextTick) return Vue.nextTick().then(() => {
.then(() => { expect(getDropdownHeaders().length).toBe(0);
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 search is active', done => { it('does not show when search is active', () => {
fillSearchBox('Random string'); fillSearchBox('Random string');
vm.$nextTick() return Vue.nextTick().then(() => {
.then(() => { expect(getDropdownHeaders().length).toBe(0);
const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header'); });
const expectedCount = 0;
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
}); });
}); });
}); });
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { GlLoadingIcon, GlLink, GlBadge, GlFormInput, GlAlert, GlSprintf } from '@gitlab/ui'; import {
import LoadingButton from '~/vue_shared/components/loading_button.vue'; GlButton,
GlLoadingIcon,
GlLink,
GlBadge,
GlFormInput,
GlAlert,
GlSprintf,
} from '@gitlab/ui';
import Stacktrace from '~/error_tracking/components/stacktrace.vue'; import Stacktrace from '~/error_tracking/components/stacktrace.vue';
import ErrorDetails from '~/error_tracking/components/error_details.vue'; import ErrorDetails from '~/error_tracking/components/error_details.vue';
import { import {
...@@ -28,7 +35,7 @@ describe('ErrorDetails', () => { ...@@ -28,7 +35,7 @@ describe('ErrorDetails', () => {
function mountComponent() { function mountComponent() {
wrapper = shallowMount(ErrorDetails, { wrapper = shallowMount(ErrorDetails, {
stubs: { LoadingButton, GlSprintf }, stubs: { GlButton, GlSprintf },
localVue, localVue,
store, store,
mocks, mocks,
...@@ -127,7 +134,7 @@ describe('ErrorDetails', () => { ...@@ -127,7 +134,7 @@ describe('ErrorDetails', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.find(Stacktrace).exists()).toBe(false); expect(wrapper.find(Stacktrace).exists()).toBe(false);
expect(wrapper.find(GlBadge).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', () => { describe('Badges', () => {
...@@ -226,7 +233,7 @@ describe('ErrorDetails', () => { ...@@ -226,7 +233,7 @@ describe('ErrorDetails', () => {
it('should submit the form', () => { it('should submit the form', () => {
window.HTMLFormElement.prototype.submit = () => {}; window.HTMLFormElement.prototype.submit = () => {};
const submitSpy = jest.spyOn(wrapper.vm.$refs.sentryIssueForm, '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(); expect(submitSpy).toHaveBeenCalled();
submitSpy.mockRestore(); submitSpy.mockRestore();
}); });
...@@ -255,14 +262,14 @@ describe('ErrorDetails', () => { ...@@ -255,14 +262,14 @@ describe('ErrorDetails', () => {
}); });
it('marks error as ignored when ignore button is clicked', () => { 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(actions.updateIgnoreStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.IGNORED }), expect.objectContaining({ status: errorStatus.IGNORED }),
); );
}); });
it('marks error as resolved when resolve button is clicked', () => { 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(actions.updateResolveStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.RESOLVED }), expect.objectContaining({ status: errorStatus.RESOLVED }),
); );
...@@ -281,14 +288,14 @@ describe('ErrorDetails', () => { ...@@ -281,14 +288,14 @@ describe('ErrorDetails', () => {
}); });
it('marks error as unresolved when ignore button is clicked', () => { 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(actions.updateIgnoreStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.UNRESOLVED }), expect.objectContaining({ status: errorStatus.UNRESOLVED }),
); );
}); });
it('marks error as resolved when resolve button is clicked', () => { 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(actions.updateResolveStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.RESOLVED }), expect.objectContaining({ status: errorStatus.RESOLVED }),
); );
...@@ -307,14 +314,14 @@ describe('ErrorDetails', () => { ...@@ -307,14 +314,14 @@ describe('ErrorDetails', () => {
}); });
it('marks error as ignored when ignore button is clicked', () => { 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(actions.updateIgnoreStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.IGNORED }), expect.objectContaining({ status: errorStatus.IGNORED }),
); );
}); });
it('marks error as unresolved when unresolve button is clicked', () => { 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(actions.updateResolveStatus.mock.calls[0][1]).toEqual(
expect.objectContaining({ status: errorStatus.UNRESOLVED }), expect.objectContaining({ status: errorStatus.UNRESOLVED }),
); );
......
...@@ -42,9 +42,6 @@ describe('ErrorTrackingList', () => { ...@@ -42,9 +42,6 @@ describe('ErrorTrackingList', () => {
...stubChildren(ErrorTrackingList), ...stubChildren(ErrorTrackingList),
...stubs, ...stubs,
}, },
data() {
return { errorSearchQuery: 'search' };
},
}); });
} }
...@@ -164,8 +161,9 @@ describe('ErrorTrackingList', () => { ...@@ -164,8 +161,9 @@ describe('ErrorTrackingList', () => {
}); });
it('it searches by query', () => { it('it searches by query', () => {
findSearchBox().vm.$emit('input', 'search');
findSearchBox().trigger('keyup.enter'); 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', () => { it('it sorts by fields', () => {
......
...@@ -72,7 +72,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` ...@@ -72,7 +72,7 @@ exports[`Dashboard template matches the default snapshot 1`] = `
</gl-form-group-stub> </gl-form-group-stub>
<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="Show last"
label-for="monitor-time-window-dropdown" label-for="monitor-time-window-dropdown"
label-size="sm" label-size="sm"
...@@ -83,6 +83,21 @@ exports[`Dashboard template matches the default snapshot 1`] = ` ...@@ -83,6 +83,21 @@ exports[`Dashboard template matches the default snapshot 1`] = `
/> />
</gl-form-group-stub> </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>
</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', () => { ...@@ -190,7 +190,8 @@ describe('Time series component', () => {
it('formats tooltip content', () => { it('formats tooltip content', () => {
const name = 'Total'; const name = 'Total';
const value = '5.556'; const value = '5.556MB';
const dataIndex = 0; const dataIndex = 0;
const seriesLabel = timeSeriesChart.find(GlChartSeriesLabel); const seriesLabel = timeSeriesChart.find(GlChartSeriesLabel);
...@@ -348,9 +349,9 @@ describe('Time series component', () => { ...@@ -348,9 +349,9 @@ describe('Time series component', () => {
}); });
}); });
it('additional y axis data', () => { it('additional y-axis data', () => {
const mockCustomYAxisOption = { const mockCustomYAxisOption = {
name: 'Custom y axis label', name: 'Custom y-axis label',
axisLabel: { axisLabel: {
formatter: jest.fn(), formatter: jest.fn(),
}, },
...@@ -397,8 +398,8 @@ describe('Time series component', () => { ...@@ -397,8 +398,8 @@ describe('Time series component', () => {
deploymentFormatter = getChartOptions().yAxis[1].axisLabel.formatter; deploymentFormatter = getChartOptions().yAxis[1].axisLabel.formatter;
}); });
it('rounds to 3 decimal places', () => { it('formats and rounds to 2 decimal places', () => {
expect(dataFormatter(0.88888)).toBe('0.889'); expect(dataFormatter(0.88888)).toBe('0.89MB');
}); });
it('deployment formatter is set as is required to display a tooltip', () => { it('deployment formatter is set as is required to display a tooltip', () => {
...@@ -421,7 +422,7 @@ describe('Time series component', () => { ...@@ -421,7 +422,7 @@ describe('Time series component', () => {
}); });
describe('yAxisLabel', () => { describe('yAxisLabel', () => {
it('y axis is configured correctly', () => { it('y-axis is configured correctly', () => {
const { yAxis } = getChartOptions(); const { yAxis } = getChartOptions();
expect(yAxis).toHaveLength(2); expect(yAxis).toHaveLength(2);
......
...@@ -214,6 +214,19 @@ describe('Dashboard', () => { ...@@ -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', () => { describe('when one of the metrics is missing', () => {
beforeEach(() => { beforeEach(() => {
createShallowWrapper({ hasMetrics: true }); createShallowWrapper({ hasMetrics: true });
......
...@@ -393,13 +393,16 @@ export const metricsDashboardPayload = { ...@@ -393,13 +393,16 @@ export const metricsDashboardPayload = {
type: 'area-chart', type: 'area-chart',
y_label: 'Total Memory Used', y_label: 'Total Memory Used',
weight: 4, weight: 4,
y_axis: {
format: 'megabytes',
},
metrics: [ metrics: [
{ {
id: 'system_metrics_kubernetes_container_memory_total', id: 'system_metrics_kubernetes_container_memory_total',
query_range: 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', label: 'Total',
unit: 'GB', unit: 'MB',
metric_id: 12, metric_id: 12,
prometheus_endpoint_path: 'http://test', prometheus_endpoint_path: 'http://test',
}, },
......
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { import {
uniqMetricsId, uniqMetricsId,
parseEnvironmentsResponse, parseEnvironmentsResponse,
...@@ -44,6 +45,11 @@ describe('mapToDashboardViewModel', () => { ...@@ -44,6 +45,11 @@ describe('mapToDashboardViewModel', () => {
title: 'Title A', title: 'Title A',
type: 'chart-type', type: 'chart-type',
y_label: 'Y Label A', y_label: 'Y Label A',
yAxis: {
name: 'Y Label A',
format: 'number',
precision: 2,
},
metrics: [], metrics: [],
}, },
], ],
...@@ -90,6 +96,98 @@ describe('mapToDashboardViewModel', () => { ...@@ -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', () => { describe('metrics mapping', () => {
const defaultLabel = 'Panel Label'; const defaultLabel = 'Panel Label';
const dashboardWithMetric = (metric, label = defaultLabel) => ({ const dashboardWithMetric = (metric, label = defaultLabel) => ({
......
...@@ -49,7 +49,7 @@ describe Sentry::Client::Issue do ...@@ -49,7 +49,7 @@ describe Sentry::Client::Issue do
it_behaves_like 'calls sentry api' it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error 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 shared_examples 'has correct external_url' do
context 'external_url' do context 'external_url' do
...@@ -184,7 +184,7 @@ describe Sentry::Client::Issue do ...@@ -184,7 +184,7 @@ describe Sentry::Client::Issue do
it_behaves_like 'calls sentry api' it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error 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
context 'when cursor is present' do context 'when cursor is present' do
...@@ -194,7 +194,7 @@ describe Sentry::Client::Issue do ...@@ -194,7 +194,7 @@ describe Sentry::Client::Issue do
it_behaves_like 'calls sentry api' it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error 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
end end
......
...@@ -102,7 +102,7 @@ describe Clusters::Applications::Ingress do ...@@ -102,7 +102,7 @@ describe Clusters::Applications::Ingress do
it 'is initialized with ingress arguments' do it 'is initialized with ingress arguments' do
expect(subject.name).to eq('ingress') expect(subject.name).to eq('ingress')
expect(subject.chart).to eq('stable/nginx-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).to be_rbac
expect(subject.files).to eq(ingress.files) expect(subject.files).to eq(ingress.files)
end end
...@@ -119,7 +119,7 @@ describe Clusters::Applications::Ingress do ...@@ -119,7 +119,7 @@ describe Clusters::Applications::Ingress do
let(:ingress) { create(:clusters_applications_ingress, :errored, version: 'nginx') } let(:ingress) { create(:clusters_applications_ingress, :errored, version: 'nginx') }
it 'is initialized with the locked version' do 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 end
end end
...@@ -135,6 +135,7 @@ describe Clusters::Applications::Ingress do ...@@ -135,6 +135,7 @@ describe Clusters::Applications::Ingress do
expect(values).to include('repository') expect(values).to include('repository')
expect(values).to include('stats') expect(values).to include('stats')
expect(values).to include('podAnnotations') expect(values).to include('podAnnotations')
expect(values).to include('clusterIP')
end end
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 ...@@ -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(:namespace) }
it { is_expected.to have_one(:status) } it { is_expected.to have_one(:status) }
it { is_expected.to have_one(:max_access_level_membership) } 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(:snippets).dependent(:destroy) }
it { is_expected.to have_many(:members) } it { is_expected.to have_many(:members) }
it { is_expected.to have_many(:project_members) } it { is_expected.to have_many(:project_members) }
...@@ -4318,4 +4319,19 @@ describe User, :do_not_mock_admin_mode do ...@@ -4318,4 +4319,19 @@ describe User, :do_not_mock_admin_mode do
expect(user.hook_attrs).to eq(user_attributes) expect(user.hook_attrs).to eq(user_attributes)
end end
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 end
...@@ -330,6 +330,21 @@ describe API::Users, :do_not_mock_admin_mode do ...@@ -330,6 +330,21 @@ describe API::Users, :do_not_mock_admin_mode do
expect(json_response.keys).not_to include 'last_sign_in_ip' expect(json_response.keys).not_to include 'last_sign_in_ip'
end 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 context 'when authenticated as admin' do
it 'includes the `is_admin` field' do it 'includes the `is_admin` field' do
get api("/users/#{user.id}", admin) get api("/users/#{user.id}", admin)
......
...@@ -86,7 +86,7 @@ describe Clusters::UpdateService do ...@@ -86,7 +86,7 @@ describe Clusters::UpdateService do
it 'rejects changes' do it 'rejects changes' do
is_expected.to eq(false) 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 end
end end
......
...@@ -64,6 +64,13 @@ describe Users::UpdateService do ...@@ -64,6 +64,13 @@ describe Users::UpdateService do
end.not_to change { user.name } end.not_to change { user.name }
end 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) def update_user(user, opts)
described_class.new(user, opts.merge(user: user)).execute described_class.new(user, opts.merge(user: user)).execute
end end
......
...@@ -109,7 +109,7 @@ RSpec.shared_examples 'time tracking endpoints' do |issuable_name| ...@@ -109,7 +109,7 @@ RSpec.shared_examples 'time tracking endpoints' do |issuable_name|
end end
expect(response).to have_gitlab_http_status(:bad_request) 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 end
end end
......
...@@ -6,3 +6,8 @@ controller: ...@@ -6,3 +6,8 @@ controller:
podAnnotations: podAnnotations:
prometheus.io/scrape: "true" prometheus.io/scrape: "true"
prometheus.io/port: "10254" 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