Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-06-03 09:08:47 +00:00
parent 6b7b853dff
commit 31a432e38a
46 changed files with 1366 additions and 272 deletions

View File

@ -252,8 +252,6 @@ GitlabSecurity/PublicSend:
Gitlab/DuplicateSpecLocation:
Exclude:
- ee/spec/lib/gitlab/gl_repository_spec.rb
- ee/spec/services/merge_requests/refresh_service_spec.rb
- ee/spec/services/ee/merge_requests/refresh_service_spec.rb
Cop/InjectEnterpriseEditionModule:
Enabled: true

View File

@ -117,6 +117,12 @@ export default {
showForm() {
this.isFormRendered = true;
},
handleReplyFormBlur() {
if (this.discussionComment) {
return;
}
this.isFormRendered = false;
},
},
createNoteMutation,
};
@ -142,7 +148,7 @@ export default {
v-if="!isFormRendered"
class="qa-discussion-reply"
:button-text="__('Reply...')"
@onClick="showForm"
@onMouseDown="showForm"
/>
<apollo-mutation
v-else
@ -161,6 +167,7 @@ export default {
:markdown-preview-path="markdownPreviewPath"
@submitForm="mutate"
@cancelForm="hideForm"
@onBlur="handleReplyFormBlur"
/>
</apollo-mutation>
</div>

View File

@ -103,6 +103,7 @@ export default {
@keydown.meta.enter="submitForm"
@keydown.ctrl.enter="submitForm"
@keyup.esc.stop="cancelComment"
@blur="$emit('onBlur')"
>
</textarea>
</template>

View File

@ -0,0 +1,152 @@
<script>
import { capitalize, lowerCase, isEmpty } from 'lodash';
import { __, sprintf } from '~/locale';
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui';
export default {
name: 'DynamicField',
components: {
GlFormGroup,
GlFormCheckbox,
GlFormInput,
GlFormSelect,
GlFormTextarea,
},
props: {
choices: {
type: Array,
required: false,
default: null,
},
help: {
type: String,
required: false,
default: null,
},
name: {
type: String,
required: true,
},
placeholder: {
type: String,
required: false,
default: null,
},
required: {
type: Boolean,
required: false,
},
title: {
type: String,
required: false,
default: null,
},
type: {
type: String,
required: true,
},
value: {
type: String,
required: false,
default: null,
},
},
data() {
return {
model: this.value,
};
},
computed: {
isCheckbox() {
return this.type === 'checkbox';
},
isPassword() {
return this.type === 'password';
},
isSelect() {
return this.type === 'select';
},
isTextarea() {
return this.type === 'textarea';
},
isNonEmptyPassword() {
return this.isPassword && !isEmpty(this.value);
},
label() {
if (this.isNonEmptyPassword) {
return sprintf(__('Enter new %{field_title}'), {
field_title: this.humanizedTitle,
});
}
return this.humanizedTitle;
},
humanizedTitle() {
return this.title || capitalize(lowerCase(this.name));
},
passwordRequired() {
return isEmpty(this.value) && this.required;
},
options() {
return this.choices.map(choice => {
return {
value: choice[1],
text: choice[0],
};
});
},
fieldId() {
return `service_${this.name}`;
},
fieldName() {
return `service[${this.name}]`;
},
sharedProps() {
return {
id: this.fieldId,
name: this.fieldName,
};
},
},
created() {
if (this.isNonEmptyPassword) {
this.model = null;
}
},
};
</script>
<template>
<gl-form-group :label="label" :label-for="fieldId" :description="help">
<template v-if="isCheckbox">
<input :name="fieldName" type="hidden" value="false" />
<gl-form-checkbox v-model="model" v-bind="sharedProps">
{{ humanizedTitle }}
</gl-form-checkbox>
</template>
<gl-form-select v-else-if="isSelect" v-model="model" v-bind="sharedProps" :options="options" />
<gl-form-textarea
v-else-if="isTextarea"
v-model="model"
v-bind="sharedProps"
:placeholder="placeholder"
:required="required"
/>
<gl-form-input
v-else-if="isPassword"
v-model="model"
v-bind="sharedProps"
:type="type"
autocomplete="new-password"
:placeholder="placeholder"
:required="passwordRequired"
/>
<gl-form-input
v-else
v-model="model"
v-bind="sharedProps"
:type="type"
:placeholder="placeholder"
:required="required"
/>
</gl-form-group>
</template>

View File

@ -2,6 +2,7 @@
import ActiveToggle from './active_toggle.vue';
import JiraTriggerFields from './jira_trigger_fields.vue';
import TriggerFields from './trigger_fields.vue';
import DynamicField from './dynamic_field.vue';
export default {
name: 'IntegrationForm',
@ -9,6 +10,7 @@ export default {
ActiveToggle,
JiraTriggerFields,
TriggerFields,
DynamicField,
},
props: {
activeToggleProps: {
@ -28,6 +30,11 @@ export default {
required: false,
default: () => [],
},
fields: {
type: Array,
required: false,
default: () => [],
},
type: {
type: String,
required: true,
@ -46,5 +53,6 @@ export default {
<active-toggle v-if="showActive" v-bind="activeToggleProps" />
<jira-trigger-fields v-if="isJira" v-bind="triggerFieldsProps" />
<trigger-fields v-else-if="triggerEvents.length" :events="triggerEvents" :type="type" />
<dynamic-field v-for="field in fields" :key="field.name" v-bind="field" />
</div>
</template>

View File

@ -15,7 +15,7 @@ export default el => {
return result;
}
const { type, commentDetail, triggerEvents, ...booleanAttributes } = el.dataset;
const { type, commentDetail, triggerEvents, fields, ...booleanAttributes } = el.dataset;
const {
showActive,
activated,
@ -41,6 +41,7 @@ export default el => {
initialCommentDetail: commentDetail,
},
triggerEvents: JSON.parse(triggerEvents),
fields: JSON.parse(fields),
},
});
},

View File

@ -17,6 +17,7 @@ export default {
class="js-vue-discussion-reply btn btn-text-field"
:title="s__('MergeRequests|Add a reply')"
@click="$emit('onClick')"
@mousedown.prevent="$emit('onMouseDown')"
>
{{ buttonText }}
</button>

View File

@ -1,20 +1,17 @@
<script>
import { GlDeprecatedButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
import { GlIcon, GlDeprecatedButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
defaultTimeRanges,
defaultTimeRange,
isValidDate,
stringToISODate,
ISODateToString,
truncateZerosInDateTime,
isDateTimePickerInputValid,
isValidInputString,
inputStringToIsoDate,
isoDateToInputString,
} from './date_time_picker_lib';
const events = {
@ -24,13 +21,13 @@ const events = {
export default {
components: {
Icon,
TooltipOnTruncate,
DateTimePickerInput,
GlFormGroup,
GlIcon,
GlDeprecatedButton,
GlDropdown,
GlDropdownItem,
GlFormGroup,
TooltipOnTruncate,
DateTimePickerInput,
},
props: {
value: {
@ -48,20 +45,41 @@ export default {
required: false,
default: true,
},
utc: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
timeRange: this.value,
startDate: '',
endDate: '',
/**
* Valid start iso date string, null if not valid value
*/
startDate: null,
/**
* Invalid start date string as input by the user
*/
startFallbackVal: '',
/**
* Valid end iso date string, null if not valid value
*/
endDate: null,
/**
* Invalid end date string as input by the user
*/
endFallbackVal: '',
};
},
computed: {
startInputValid() {
return isValidDate(this.startDate);
return isValidInputString(this.startDate);
},
endInputValid() {
return isValidDate(this.endDate);
return isValidInputString(this.endDate);
},
isValid() {
return this.startInputValid && this.endInputValid;
@ -69,21 +87,31 @@ export default {
startInput: {
get() {
return this.startInputValid ? this.formatDate(this.startDate) : this.startDate;
return this.dateToInput(this.startDate) || this.startFallbackVal;
},
set(val) {
// Attempt to set a formatted date if possible
this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
try {
this.startDate = this.inputToDate(val);
this.startFallbackVal = null;
} catch (e) {
this.startDate = null;
this.startFallbackVal = val;
}
this.timeRange = null;
},
},
endInput: {
get() {
return this.endInputValid ? this.formatDate(this.endDate) : this.endDate;
return this.dateToInput(this.endDate) || this.endFallbackVal;
},
set(val) {
// Attempt to set a formatted date if possible
this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
try {
this.endDate = this.inputToDate(val);
this.endFallbackVal = null;
} catch (e) {
this.endDate = null;
this.endFallbackVal = val;
}
this.timeRange = null;
},
},
@ -96,10 +124,10 @@ export default {
}
const { start, end } = convertToFixedRange(this.value);
if (isValidDate(start) && isValidDate(end)) {
if (isValidInputString(start) && isValidInputString(end)) {
return sprintf(__('%{start} to %{end}'), {
start: this.formatDate(start),
end: this.formatDate(end),
start: this.stripZerosInDateTime(this.dateToInput(start)),
end: this.stripZerosInDateTime(this.dateToInput(end)),
});
}
} catch {
@ -107,6 +135,13 @@ export default {
}
return '';
},
customLabel() {
if (this.utc) {
return __('Custom range (UTC)');
}
return __('Custom range');
},
},
watch: {
value(newValue) {
@ -132,8 +167,17 @@ export default {
}
},
methods: {
formatDate(date) {
return truncateZerosInDateTime(ISODateToString(date));
dateToInput(date) {
if (date === null) {
return null;
}
return isoDateToInputString(date, this.utc);
},
inputToDate(value) {
return inputStringToIsoDate(value, this.utc);
},
stripZerosInDateTime(str = '') {
return str.replace(' 00:00:00', '');
},
closeDropdown() {
this.$refs.dropdown.hide();
@ -169,10 +213,16 @@ export default {
menu-class="date-time-picker-menu"
toggle-class="date-time-picker-toggle text-truncate"
>
<template #button-content>
<span class="gl-flex-grow-1 text-truncate">{{ timeWindowText }}</span>
<span v-if="utc" class="text-muted gl-font-weight-bold gl-font-sm">{{ __('UTC') }}</span>
<gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" />
</template>
<div class="d-flex justify-content-between gl-p-2-deprecated-no-really-do-not-use-me">
<gl-form-group
v-if="customEnabled"
:label="__('Custom range')"
:label="customLabel"
label-for="custom-from-time"
label-class="gl-pb-1-deprecated-no-really-do-not-use-me"
class="custom-time-range-form-group col-md-7 gl-pl-1-deprecated-no-really-do-not-use-me gl-pr-0 m-0"
@ -214,7 +264,7 @@ export default {
active-class="active"
@click="setQuickRange(option)"
>
<icon
<gl-icon
name="mobile-issue-close"
class="align-bottom"
:class="{ invisible: !isOptionActive(option) }"

View File

@ -6,9 +6,9 @@ import { dateFormats } from './date_time_picker_lib';
const inputGroupText = {
invalidFeedback: sprintf(__('Format: %{dateFormat}'), {
dateFormat: dateFormats.stringDate,
dateFormat: dateFormats.inputFormat,
}),
placeholder: dateFormats.stringDate,
placeholder: dateFormats.inputFormat,
};
export default {

View File

@ -1,12 +1,6 @@
import dateformat from 'dateformat';
import { __ } from '~/locale';
/**
* Valid strings for this regex are
* 2019-10-01 and 2019-10-01 01:02:03
*/
const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/;
/**
* Default time ranges for the date picker.
* @see app/assets/javascripts/lib/utils/datetime_range.js
@ -34,23 +28,33 @@ export const defaultTimeRanges = [
export const defaultTimeRange = defaultTimeRanges.find(tr => tr.default);
export const dateFormats = {
ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
stringDate: 'yyyy-mm-dd HH:MM:ss',
/**
* Format used by users to input dates
*
* Note: Should be a format that can be parsed by Date.parse.
*/
inputFormat: 'yyyy-mm-dd HH:MM:ss',
/**
* Format used to strip timezone from inputs
*/
stripTimezoneFormat: "yyyy-mm-dd'T'HH:MM:ss'Z'",
};
/**
* The URL params start and end need to be validated
* before passing them down to other components.
* Returns true if the date can be parsed succesfully after
* being typed by a user.
*
* @param {string} dateString
* @returns true if the string is a valid date, false otherwise
* It allows some ambiguity so validation is not strict.
*
* @param {string} value - Value as typed by the user
* @returns true if the value can be parsed as a valid date, false otherwise
*/
export const isValidDate = dateString => {
export const isValidInputString = value => {
try {
// dateformat throws error that can be caught.
// This is better than using `new Date()`
if (dateString && dateString.trim()) {
dateformat(dateString, 'isoDateTime');
if (value && value.trim()) {
dateformat(value, 'isoDateTime');
return true;
}
return false;
@ -60,25 +64,30 @@ export const isValidDate = dateString => {
};
/**
* Convert the input in Time picker component to ISO date.
* Convert the input in time picker component to an ISO date.
*
* @param {string} val
* @returns {string}
* @param {string} value
* @param {Boolean} utc - If true, it forces the date to by
* formatted using UTC format, ignoring the local time.
* @returns {Date}
*/
export const stringToISODate = val =>
dateformat(new Date(val.replace(/-/g, '/')), dateFormats.ISODate, true);
export const inputStringToIsoDate = (value, utc = false) => {
let date = new Date(value);
if (utc) {
// Forces date to be interpreted as UTC by stripping the timezone
// by formatting to a string with 'Z' and skipping timezone
date = dateformat(date, dateFormats.stripTimezoneFormat);
}
return dateformat(date, 'isoUtcDateTime');
};
/**
* Convert the ISO date received from the URL to string
* for the Time picker component.
* Converts a iso date string to a formatted string for the Time picker component.
*
* @param {Date} date
* @param {String} ISO Formatted date
* @returns {string}
*/
export const ISODateToString = date => dateformat(date, dateFormats.stringDate);
export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', '');
export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val);
export const isoDateToInputString = (date, utc = false) =>
dateformat(date, dateFormats.inputFormat, utc);
export default {};

View File

@ -98,6 +98,28 @@ module ServicesHelper
end
end
def integration_form_refactor?
Feature.enabled?(:integration_form_refactor, @project)
end
def trigger_events_for_service
return [] unless integration_form_refactor?
ServiceEventSerializer.new(service: @service).represent(@service.configurable_events).to_json
end
def fields_for_service
return [] unless integration_form_refactor?
ServiceFieldSerializer.new(service: @service).represent(@service.global_fields).to_json
end
def show_service_trigger_events?
return false if @service.is_a?(JiraService) || integration_form_refactor?
@service.configurable_events.present?
end
extend self
end

View File

@ -50,6 +50,7 @@ class Blob < SimpleDelegator
BlobViewer::License,
BlobViewer::Contributing,
BlobViewer::Changelog,
BlobViewer::MetricsDashboardYml,
BlobViewer::CargoToml,
BlobViewer::Cartfile,

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
module BlobViewer
class MetricsDashboardYml < Base
include ServerSide
include Gitlab::Utils::StrongMemoize
include Auxiliary
self.partial_name = 'metrics_dashboard_yml'
self.loading_partial_name = 'metrics_dashboard_yml_loading'
self.file_types = %i(metrics_dashboard)
self.binary = false
def valid?
errors.blank?
end
def errors
strong_memoize(:errors) do
prepare!
parse_blob_data
end
end
private
def parse_blob_data
::PerformanceMonitoring::PrometheusDashboard.from_json(YAML.safe_load(blob.data))
nil
rescue Psych::SyntaxError => error
wrap_yml_syntax_error(error)
rescue ActiveModel::ValidationError => invalid
invalid.model.errors
end
def wrap_yml_syntax_error(error)
::PerformanceMonitoring::PrometheusDashboard.new.errors.tap do |errors|
errors.add(:'YAML syntax', error.message)
end
end
end
end

View File

@ -13,7 +13,7 @@ module PerformanceMonitoring
def from_json(json_content)
dashboard = new(
dashboard: json_content['dashboard'],
panel_groups: json_content['panel_groups'].map { |group| PrometheusPanelGroup.from_json(group) }
panel_groups: json_content['panel_groups']&.map { |group| PrometheusPanelGroup.from_json(group) }
)
dashboard.tap(&:validate!)

View File

@ -15,7 +15,7 @@ module PerformanceMonitoring
title: json_content['title'],
y_label: json_content['y_label'],
weight: json_content['weight'],
metrics: json_content['metrics'].map { |metric| PrometheusMetric.from_json(metric) }
metrics: json_content['metrics']&.map { |metric| PrometheusMetric.from_json(metric) }
)
panel.tap(&:validate!)

View File

@ -13,7 +13,7 @@ module PerformanceMonitoring
panel_group = new(
group: json_content['group'],
priority: json_content['priority'],
panels: json_content['panels'].map { |panel| PrometheusPanel.from_json(panel) }
panels: json_content['panels']&.map { |panel| PrometheusPanel.from_json(panel) }
)
panel_group.tap(&:validate!)

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
class ServiceFieldEntity < Grape::Entity
include RequestAwareEntity
expose :type, :name, :title, :placeholder, :required, :choices, :help
expose :value do |field|
# field[:name] is not user input and so can assume is safe
value = service.public_send(field[:name]) # rubocop:disable GitlabSecurity/PublicSend
if field[:type] == 'password' && value.present?
'true'
else
value
end
end
private
def service
request.service
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class ServiceFieldSerializer < BaseSerializer
entity ServiceFieldEntity
end

View File

@ -6,6 +6,7 @@
.header-content
.title-container
%h1.title
%span.gl-sr-only GitLab
= link_to root_path, title: _('Dashboard'), id: 'logo' do
= brand_header_logo
- logo_text = brand_header_logo_type
@ -54,6 +55,8 @@
= todos_count_format(todos_pending_count)
%li.nav-item.header-help.dropdown.d-none.d-md-block
= link_to help_path, class: 'header-help-dropdown-toggle', data: { toggle: "dropdown" } do
%span.gl-sr-only
= s_('Nav|Help')
= sprite_icon('question', size: 16)
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
@ -61,7 +64,7 @@
- if header_link?(:user_dropdown)
%li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_event: "click_dropdown", track_value: "", qa_selector: 'user_menu' }, class: ('mr-0' if has_impersonation_link) }
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar"
= image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar", alt: current_user.name
= render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right

View File

@ -0,0 +1,11 @@
- if viewer.valid?
= icon('check fw')
= _('Metrics Dashboard YAML definition is valid.')
- else
= icon('warning fw')
= _('Metrics Dashboard YAML definition is invalid:')
%ul
- viewer.errors.messages.each do |error|
%li= error.join(': ')
= link_to _('Learn more'), help_page_path('user/project/integrations/prometheus.md', anchor: 'defining-custom-dashboards-per-project')

View File

@ -0,0 +1,4 @@
= icon('spinner spin fw')
= _('Metrics Dashboard YAML definition') + '…'
= link_to _('Learn more'), help_page_path('user/project/integrations/prometheus.md')

View File

@ -1,5 +1,4 @@
= form_errors(@service)
- trigger_events = Feature.enabled?(:integration_form_refactor) ? ServiceEventSerializer.new(service: @service).represent(@service.configurable_events).to_json : []
- if lookup_context.template_exists?('help', "projects/services/#{@service.to_param}", true)
= render "projects/services/#{@service.to_param}/help", subject: @service
@ -10,9 +9,9 @@
.service-settings
.js-vue-integration-settings{ data: { show_active: @service.show_active_box?.to_s, activated: (@service.active || @service.new_record?).to_s, type: @service.to_param, merge_request_events: @service.merge_requests_events.to_s,
commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on_event_enabled.to_s, comment_detail: @service.comment_detail, trigger_events: trigger_events } }
commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on_event_enabled.to_s, comment_detail: @service.comment_detail, trigger_events: trigger_events_for_service, fields: fields_for_service } }
- if @service.configurable_events.present? && !@service.is_a?(JiraService) && Feature.disabled?(:integration_form_refactor)
- if show_service_trigger_events?
.form-group.row
%label.col-form-label.col-sm-2= _('Trigger')
@ -33,5 +32,6 @@ commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on
%p.text-muted
= @service.class.event_description(event)
- @service.global_fields.each do |field|
= render 'shared/field', form: form, field: field
- unless integration_form_refactor?
- @service.global_fields.each do |field|
= render 'shared/field', form: form, field: field

View File

@ -0,0 +1,6 @@
---
title: Close open reply input fields in the design view sidebar when leaving a new
comment
merge_request: 33587
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Improve header acccessibility
merge_request: 33603
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Added validation for YAML files with metrics dashboard definitions.
merge_request: 33202
author:
type: added

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -326,6 +326,33 @@ new branch.
If you select your **default** branch, the new dashboard becomes immediately available.
If you select another branch, this branch should be merged to your **default** branch first.
#### Dashboard YAML syntax validation
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33202) in GitLab 13.1.
To confirm your dashboard definition contains valid YAML syntax:
1. Navigate to **{doc-text}** **Repository > Files**.
1. Navigate to your dashboard file in your repository.
1. Review the information pane about the file, displayed above the file contents.
Files with valid syntax display **Metrics Dashboard YAML definition is valid**,
and files with invalid syntax display **Metrics Dashboard YAML definition is invalid**.
![Metrics Dashboard_YAML_syntax_validation](img/prometheus_dashboard_yaml_validation_v13_1.png)
When **Metrics Dashboard YAML definition is invalid** at least one of the following messages is displayed:
1. `dashboard: can't be blank` [learn more](#dashboard-top-level-properties)
1. `panel_groups: can't be blank` [learn more](#dashboard-top-level-properties)
1. `group: can't be blank` [learn more](#panel-group-panel_groups-properties)
1. `panels: can't be blank` [learn more](#panel-group-panel_groups-properties)
1. `metrics: can't be blank` [learn more](#panel-panels-properties)
1. `title: can't be blank` [learn more](#panel-panels-properties)
1. `query: can't be blank` [learn more](#metrics-metrics-properties)
1. `query_range: can't be blank` [learn more](#metrics-metrics-properties)
1. `unit: can't be blank` [learn more](#metrics-metrics-properties)
#### Dashboard YAML properties
Dashboards have several components:

View File

@ -44,6 +44,7 @@ module Quality
views
workers
elastic_integration
tooling
],
integration: %w[
controllers

View File

@ -6665,6 +6665,9 @@ msgstr ""
msgid "Custom range"
msgstr ""
msgid "Custom range (UTC)"
msgstr ""
msgid "CustomCycleAnalytics|Add a stage"
msgstr ""
@ -13756,6 +13759,15 @@ msgstr ""
msgid "Metrics Dashboard"
msgstr ""
msgid "Metrics Dashboard YAML definition"
msgstr ""
msgid "Metrics Dashboard YAML definition is invalid:"
msgstr ""
msgid "Metrics Dashboard YAML definition is valid."
msgstr ""
msgid "Metrics and profiling"
msgstr ""
@ -14342,10 +14354,13 @@ msgstr ""
msgid "Network"
msgstr ""
msgid "NetworkPolicies|Choose whether to enforce this policy."
msgstr ""
msgid "NetworkPolicies|Define this policy's location, conditions and actions."
msgstr ""
msgid "NetworkPolicies|Enabled"
msgid "NetworkPolicies|Enforcement status"
msgstr ""
msgid "NetworkPolicies|Environment does not have deployment platform"
@ -23465,6 +23480,9 @@ msgstr ""
msgid "URL or request ID"
msgstr ""
msgid "UTC"
msgstr ""
msgid "Unable to apply suggestions to a deleted line."
msgstr ""

View File

@ -40,7 +40,7 @@
"@babel/plugin-syntax-import-meta": "^7.8.3",
"@babel/preset-env": "^7.8.4",
"@gitlab/at.js": "1.5.5",
"@gitlab/svgs": "1.133.0",
"@gitlab/svgs": "1.135.0",
"@gitlab/ui": "16.1.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3",

View File

@ -6,6 +6,8 @@ describe 'Admin activates Prometheus' do
let(:admin) { create(:user, :admin) }
before do
stub_feature_flags(integration_form_refactor: false)
sign_in(admin)
visit(admin_application_settings_services_path)

View File

@ -555,6 +555,53 @@ describe 'File blob', :js do
end
end
describe '.gitlab/dashboards/custom-dashboard.yml' do
before do
project.add_maintainer(project.creator)
Files::CreateService.new(
project,
project.creator,
start_branch: 'master',
branch_name: 'master',
commit_message: "Add .gitlab/dashboards/custom-dashboard.yml",
file_path: '.gitlab/dashboards/custom-dashboard.yml',
file_content: file_content
).execute
visit_blob('.gitlab/dashboards/custom-dashboard.yml')
end
context 'valid dashboard file' do
let(:file_content) { File.read(Rails.root.join('config/prometheus/common_metrics.yml')) }
it 'displays an auxiliary viewer' do
aggregate_failures do
# shows that dashboard yaml is valid
expect(page).to have_content('Metrics Dashboard YAML definition is valid.')
# shows a learn more link
expect(page).to have_link('Learn more')
end
end
end
context 'invalid dashboard file' do
let(:file_content) { "dashboard: 'invalid'" }
it 'displays an auxiliary viewer' do
aggregate_failures do
# shows that dashboard yaml is invalid
expect(page).to have_content('Metrics Dashboard YAML definition is invalid:')
expect(page).to have_content("panel_groups: can't be blank")
# shows a learn more link
expect(page).to have_link('Learn more')
end
end
end
end
context 'LICENSE' do
before do
visit_blob('LICENSE')

View File

@ -76,7 +76,7 @@ describe('Design discussions component', () => {
it('hides reply placeholder and opens form on placeholder click', () => {
createComponent();
findReplyPlaceholder().trigger('click');
findReplyPlaceholder().vm.$emit('onMouseDown');
return wrapper.vm.$nextTick().then(() => {
expect(findReplyPlaceholder().exists()).toBe(false);
@ -130,4 +130,22 @@ describe('Design discussions component', () => {
true,
);
});
it('closes the form on blur if the form was empty', () => {
createComponent({}, { discussionComment: '', isFormRendered: true });
findReplyForm().vm.$emit('onBlur');
return wrapper.vm.$nextTick().then(() => {
expect(findReplyForm().exists()).toBe(false);
});
});
it('keeps the form open on blur if the form had text', () => {
createComponent({}, { discussionComment: 'test', isFormRendered: true });
findReplyForm().vm.$emit('onBlur');
return wrapper.vm.$nextTick().then(() => {
expect(findReplyForm().exists()).toBe(true);
});
});
});

View File

@ -39,6 +39,13 @@ describe('Design reply form component', () => {
expect(findTextarea().element).toEqual(document.activeElement);
});
it('textarea emits onBlur event on blur', () => {
createComponent();
findTextarea().trigger('blur');
expect(wrapper.emitted('onBlur')).toBeTruthy();
});
it('renders button text as "Comment" when creating a comment', () => {
createComponent();

View File

@ -0,0 +1,179 @@
import { mount } from '@vue/test-utils';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui';
describe('DynamicField', () => {
let wrapper;
const defaultProps = {
help: 'The URL of the project',
name: 'project_url',
placeholder: 'https://jira.example.com',
title: 'Project URL',
type: 'text',
value: '1',
};
const createComponent = props => {
wrapper = mount(DynamicField, {
propsData: { ...defaultProps, ...props },
});
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findGlFormGroup = () => wrapper.find(GlFormGroup);
const findGlFormCheckbox = () => wrapper.find(GlFormCheckbox);
const findGlFormInput = () => wrapper.find(GlFormInput);
const findGlFormSelect = () => wrapper.find(GlFormSelect);
const findGlFormTextarea = () => wrapper.find(GlFormTextarea);
describe('template', () => {
describe('dynamic field', () => {
describe('type is checkbox', () => {
beforeEach(() => {
createComponent({
type: 'checkbox',
});
});
it('renders GlFormCheckbox', () => {
expect(findGlFormCheckbox().exists()).toBe(true);
});
it('does not render other types of input', () => {
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
describe('type is select', () => {
beforeEach(() => {
createComponent({
type: 'select',
choices: [['all', 'All details'], ['standard', 'Standard']],
});
});
it('renders findGlFormSelect', () => {
expect(findGlFormSelect().exists()).toBe(true);
expect(findGlFormSelect().findAll('option')).toHaveLength(2);
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
describe('type is textarea', () => {
beforeEach(() => {
createComponent({
type: 'textarea',
});
});
it('renders findGlFormTextarea', () => {
expect(findGlFormTextarea().exists()).toBe(true);
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
describe('type is password', () => {
beforeEach(() => {
createComponent({
type: 'password',
});
});
it('renders GlFormInput', () => {
expect(findGlFormInput().exists()).toBe(true);
expect(findGlFormInput().attributes('type')).toBe('password');
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
});
});
describe('type is text', () => {
beforeEach(() => {
createComponent({
type: 'text',
required: true,
});
});
it('renders GlFormInput', () => {
expect(findGlFormInput().exists()).toBe(true);
expect(findGlFormInput().attributes()).toMatchObject({
type: 'text',
id: 'service_project_url',
name: 'service[project_url]',
placeholder: defaultProps.placeholder,
required: 'required',
});
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
});
});
});
describe('help text', () => {
it('renders description with help text', () => {
createComponent();
expect(
findGlFormGroup()
.find('small')
.text(),
).toBe(defaultProps.help);
});
});
describe('label text', () => {
it('renders label with title', () => {
createComponent();
expect(
findGlFormGroup()
.find('label')
.text(),
).toBe(defaultProps.title);
});
describe('for password field with some value (hidden by backend)', () => {
it('renders label with new password title', () => {
createComponent({
type: 'password',
value: 'true',
});
expect(
findGlFormGroup()
.find('label')
.text(),
).toBe(`Enter new ${defaultProps.title}`);
});
});
});
});
});

View File

@ -3,6 +3,7 @@ import IntegrationForm from '~/integrations/edit/components/integration_form.vue
import ActiveToggle from '~/integrations/edit/components/active_toggle.vue';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
describe('IntegrationForm', () => {
let wrapper;
@ -95,5 +96,25 @@ describe('IntegrationForm', () => {
expect(findTriggerFields().props('type')).toBe(type);
});
});
describe('fields is present', () => {
it('renders DynamicField for each field', () => {
const fields = [
{ name: 'username', type: 'text' },
{ name: 'API token', type: 'password' },
];
createComponent({
fields,
});
const dynamicFields = wrapper.findAll(DynamicField);
expect(dynamicFields).toHaveLength(2);
dynamicFields.wrappers.forEach((field, index) => {
expect(field.props()).toMatchObject(fields[index]);
});
});
});
});
});

View File

@ -20,7 +20,7 @@ describe('ReplyPlaceholder', () => {
wrapper.destroy();
});
it('emits onClick even on button click', () => {
it('emits onClick event on button click', () => {
findButton().trigger('click');
return wrapper.vm.$nextTick().then(() => {
@ -30,6 +30,16 @@ describe('ReplyPlaceholder', () => {
});
});
it('emits onMouseDown event on button mousedown', () => {
findButton().trigger('mousedown');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted()).toEqual({
onMouseDown: [[]],
});
});
});
it('should render reply button', () => {
expect(findButton().text()).toEqual(buttonText);
});

View File

@ -1,7 +1,13 @@
import * as dateTimePickerLib from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
import timezoneMock from 'timezone-mock';
import {
isValidInputString,
inputStringToIsoDate,
isoDateToInputString,
} from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
describe('date time picker lib', () => {
describe('isValidDate', () => {
describe('isValidInputString', () => {
[
{
input: '2019-09-09T00:00:00.000Z',
@ -48,121 +54,137 @@ describe('date time picker lib', () => {
output: false,
},
].forEach(({ input, output }) => {
it(`isValidDate return ${output} for ${input}`, () => {
expect(dateTimePickerLib.isValidDate(input)).toBe(output);
it(`isValidInputString return ${output} for ${input}`, () => {
expect(isValidInputString(input)).toBe(output);
});
});
});
describe('stringToISODate', () => {
['', 'null', undefined, 'abc'].forEach(input => {
describe('inputStringToIsoDate', () => {
[
'',
'null',
undefined,
'abc',
'xxxx-xx-xx',
'9999-99-19',
'2019-19-23',
'2019-09-23 x',
'2019-09-29 24:24:24',
].forEach(input => {
it(`throws error for invalid input like ${input}`, () => {
expect(() => dateTimePickerLib.stringToISODate(input)).toThrow();
expect(() => inputStringToIsoDate(input)).toThrow();
});
});
[
{
input: '2019-09-09 01:01:01',
output: '2019-09-09T01:01:01Z',
input: '2019-09-08 01:01:01',
output: '2019-09-08T01:01:01Z',
},
{
input: '2019-09-09 00:00:00',
output: '2019-09-09T00:00:00Z',
input: '2019-09-08 00:00:00',
output: '2019-09-08T00:00:00Z',
},
{
input: '2019-09-09 23:59:59',
output: '2019-09-09T23:59:59Z',
input: '2019-09-08 23:59:59',
output: '2019-09-08T23:59:59Z',
},
{
input: '2019-09-09',
output: '2019-09-09T00:00:00Z',
input: '2019-09-08',
output: '2019-09-08T00:00:00Z',
},
{
input: '2019-09-08',
output: '2019-09-08T00:00:00Z',
},
{
input: '2019-09-08 00:00:00',
output: '2019-09-08T00:00:00Z',
},
{
input: '2019-09-08 23:24:24',
output: '2019-09-08T23:24:24Z',
},
{
input: '2019-09-08 0:0:0',
output: '2019-09-08T00:00:00Z',
},
].forEach(({ input, output }) => {
it(`returns ${output} from ${input}`, () => {
expect(dateTimePickerLib.stringToISODate(input)).toBe(output);
expect(inputStringToIsoDate(input)).toBe(output);
});
});
describe('timezone formatting', () => {
const value = '2019-09-08 01:01:01';
const utcResult = '2019-09-08T01:01:01Z';
const localResult = '2019-09-08T08:01:01Z';
test.each`
val | locatTimezone | utc | result
${value} | ${'UTC'} | ${undefined} | ${utcResult}
${value} | ${'UTC'} | ${false} | ${utcResult}
${value} | ${'UTC'} | ${true} | ${utcResult}
${value} | ${'US/Pacific'} | ${undefined} | ${localResult}
${value} | ${'US/Pacific'} | ${false} | ${localResult}
${value} | ${'US/Pacific'} | ${true} | ${utcResult}
`(
'when timezone is $locatTimezone, formats $result for utc = $utc',
({ val, locatTimezone, utc, result }) => {
timezoneMock.register(locatTimezone);
expect(inputStringToIsoDate(val, utc)).toBe(result);
timezoneMock.unregister();
},
);
});
});
describe('truncateZerosInDateTime', () => {
describe('isoDateToInputString', () => {
[
{
input: '',
output: '',
input: '2019-09-08T01:01:01Z',
output: '2019-09-08 01:01:01',
},
{
input: '2019-10-10',
output: '2019-10-10',
input: '2019-09-08T01:01:01.999Z',
output: '2019-09-08 01:01:01',
},
{
input: '2019-10-10 00:00:01',
output: '2019-10-10 00:00:01',
},
{
input: '2019-10-10 00:00:00',
output: '2019-10-10',
},
].forEach(({ input, output }) => {
it(`truncateZerosInDateTime return ${output} for ${input}`, () => {
expect(dateTimePickerLib.truncateZerosInDateTime(input)).toBe(output);
});
});
});
describe('isDateTimePickerInputValid', () => {
[
{
input: null,
output: false,
},
{
input: '',
output: false,
},
{
input: 'xxxx-xx-xx',
output: false,
},
{
input: '9999-99-19',
output: false,
},
{
input: '2019-19-23',
output: false,
},
{
input: '2019-09-23',
output: true,
},
{
input: '2019-09-23 x',
output: false,
},
{
input: '2019-09-29 0:0:0',
output: false,
},
{
input: '2019-09-29 00:00:00',
output: true,
},
{
input: '2019-09-29 24:24:24',
output: false,
},
{
input: '2019-09-29 23:24:24',
output: true,
},
{
input: '2019-09-29 23:24:24 ',
output: false,
input: '2019-09-08T00:00:00Z',
output: '2019-09-08 00:00:00',
},
].forEach(({ input, output }) => {
it(`returns ${output} for ${input}`, () => {
expect(dateTimePickerLib.isDateTimePickerInputValid(input)).toBe(output);
expect(isoDateToInputString(input)).toBe(output);
});
});
describe('timezone formatting', () => {
const value = '2019-09-08T08:01:01Z';
const utcResult = '2019-09-08 08:01:01';
const localResult = '2019-09-08 01:01:01';
test.each`
val | locatTimezone | utc | result
${value} | ${'UTC'} | ${undefined} | ${utcResult}
${value} | ${'UTC'} | ${false} | ${utcResult}
${value} | ${'UTC'} | ${true} | ${utcResult}
${value} | ${'US/Pacific'} | ${undefined} | ${localResult}
${value} | ${'US/Pacific'} | ${false} | ${localResult}
${value} | ${'US/Pacific'} | ${true} | ${utcResult}
`(
'when timezone is $locatTimezone, formats $result for utc = $utc',
({ val, locatTimezone, utc, result }) => {
timezoneMock.register(locatTimezone);
expect(isoDateToInputString(val, utc)).toBe(result);
timezoneMock.unregister();
},
);
});
});
});

View File

@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils';
import timezoneMock from 'timezone-mock';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import {
defaultTimeRanges,
@ -8,16 +9,16 @@ import {
const optionsCount = defaultTimeRanges.length;
describe('DateTimePicker', () => {
let dateTimePicker;
let wrapper;
const dropdownToggle = () => dateTimePicker.find('.dropdown-toggle');
const dropdownMenu = () => dateTimePicker.find('.dropdown-menu');
const applyButtonElement = () => dateTimePicker.find('button.btn-success').element;
const findQuickRangeItems = () => dateTimePicker.findAll('.dropdown-item');
const cancelButtonElement = () => dateTimePicker.find('button.btn-secondary').element;
const dropdownToggle = () => wrapper.find('.dropdown-toggle');
const dropdownMenu = () => wrapper.find('.dropdown-menu');
const applyButtonElement = () => wrapper.find('button.btn-success').element;
const findQuickRangeItems = () => wrapper.findAll('.dropdown-item');
const cancelButtonElement = () => wrapper.find('button.btn-secondary').element;
const createComponent = props => {
dateTimePicker = mount(DateTimePicker, {
wrapper = mount(DateTimePicker, {
propsData: {
...props,
},
@ -25,54 +26,86 @@ describe('DateTimePicker', () => {
};
afterEach(() => {
dateTimePicker.destroy();
wrapper.destroy();
});
it('renders dropdown toggle button with selected text', done => {
it('renders dropdown toggle button with selected text', () => {
createComponent();
dateTimePicker.vm.$nextTick(() => {
return wrapper.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe(defaultTimeRange.label);
done();
});
});
it('renders dropdown toggle button with selected text and utc label', () => {
createComponent({ utc: true });
return wrapper.vm.$nextTick(() => {
expect(dropdownToggle().text()).toContain(defaultTimeRange.label);
expect(dropdownToggle().text()).toContain('UTC');
});
});
it('renders dropdown with 2 custom time range inputs', () => {
createComponent();
dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.findAll('input').length).toBe(2);
return wrapper.vm.$nextTick(() => {
expect(wrapper.findAll('input').length).toBe(2);
});
});
it('renders inputs with h/m/s truncated if its all 0s', done => {
createComponent({
value: {
describe('renders label with h/m/s truncated if possible', () => {
[
{
start: '2019-10-10T00:00:00.000Z',
end: '2019-10-10T00:00:00.000Z',
label: '2019-10-10 to 2019-10-10',
},
{
start: '2019-10-10T00:00:00.000Z',
end: '2019-10-14T00:10:00.000Z',
label: '2019-10-10 to 2019-10-14 00:10:00',
},
});
dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10');
expect(dateTimePicker.find('#custom-time-to').element.value).toBe('2019-10-14 00:10:00');
done();
{
start: '2019-10-10T00:00:00.000Z',
end: '2019-10-10T00:00:01.000Z',
label: '2019-10-10 to 2019-10-10 00:00:01',
},
{
start: '2019-10-10T00:00:01.000Z',
end: '2019-10-10T00:00:01.000Z',
label: '2019-10-10 00:00:01 to 2019-10-10 00:00:01',
},
{
start: '2019-10-10T00:00:01.000Z',
end: '2019-10-10T00:00:01.000Z',
utc: true,
label: '2019-10-10 00:00:01 to 2019-10-10 00:00:01 UTC',
},
].forEach(({ start, end, utc, label }) => {
it(`for start ${start}, end ${end}, and utc ${utc}, label is ${label}`, () => {
createComponent({
value: { start, end },
utc,
});
return wrapper.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe(label);
});
});
});
});
it(`renders dropdown with ${optionsCount} (default) items in quick range`, done => {
it(`renders dropdown with ${optionsCount} (default) items in quick range`, () => {
createComponent();
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
return wrapper.vm.$nextTick(() => {
expect(findQuickRangeItems().length).toBe(optionsCount);
done();
});
});
it('renders dropdown with a default quick range item selected', done => {
it('renders dropdown with a default quick range item selected', () => {
createComponent();
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.find('.dropdown-item.active').exists()).toBe(true);
expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(defaultTimeRange.label);
done();
return wrapper.vm.$nextTick(() => {
expect(wrapper.find('.dropdown-item.active').exists()).toBe(true);
expect(wrapper.find('.dropdown-item.active').text()).toBe(defaultTimeRange.label);
});
});
@ -86,78 +119,128 @@ describe('DateTimePicker', () => {
describe('user input', () => {
const fillInputAndBlur = (input, val) => {
dateTimePicker.find(input).setValue(val);
return dateTimePicker.vm.$nextTick().then(() => {
dateTimePicker.find(input).trigger('blur');
return dateTimePicker.vm.$nextTick();
wrapper.find(input).setValue(val);
return wrapper.vm.$nextTick().then(() => {
wrapper.find(input).trigger('blur');
return wrapper.vm.$nextTick();
});
};
beforeEach(done => {
beforeEach(() => {
createComponent();
dateTimePicker.vm.$nextTick(done);
return wrapper.vm.$nextTick();
});
it('displays inline error message if custom time range inputs are invalid', done => {
fillInputAndBlur('#custom-time-from', '2019-10-01abc')
it('displays inline error message if custom time range inputs are invalid', () => {
return fillInputAndBlur('#custom-time-from', '2019-10-01abc')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-10abc'))
.then(() => {
expect(dateTimePicker.findAll('.invalid-feedback').length).toBe(2);
done();
})
.catch(done);
expect(wrapper.findAll('.invalid-feedback').length).toBe(2);
});
});
it('keeps apply button disabled with invalid custom time range inputs', done => {
fillInputAndBlur('#custom-time-from', '2019-10-01abc')
it('keeps apply button disabled with invalid custom time range inputs', () => {
return fillInputAndBlur('#custom-time-from', '2019-10-01abc')
.then(() => fillInputAndBlur('#custom-time-to', '2019-09-19'))
.then(() => {
expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
done();
})
.catch(done);
});
});
it('enables apply button with valid custom time range inputs', done => {
fillInputAndBlur('#custom-time-from', '2019-10-01')
it('enables apply button with valid custom time range inputs', () => {
return fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.then(() => {
expect(applyButtonElement().getAttribute('disabled')).toBeNull();
done();
})
.catch(done.fail);
});
});
it('emits dates in an object when apply is clicked', done => {
fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.then(() => {
applyButtonElement().click();
describe('when "apply" is clicked', () => {
it('emits iso dates', () => {
return fillInputAndBlur('#custom-time-from', '2019-10-01 00:00:00')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19 00:00:00'))
.then(() => {
applyButtonElement().click();
expect(dateTimePicker.emitted().input).toHaveLength(1);
expect(dateTimePicker.emitted().input[0]).toEqual([
{
end: '2019-10-19T00:00:00Z',
start: '2019-10-01T00:00:00Z',
},
]);
done();
})
.catch(done.fail);
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0]).toEqual([
{
end: '2019-10-19T00:00:00Z',
start: '2019-10-01T00:00:00Z',
},
]);
});
});
it('emits iso dates, for dates without time of day', () => {
return fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.then(() => {
applyButtonElement().click();
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0]).toEqual([
{
end: '2019-10-19T00:00:00Z',
start: '2019-10-01T00:00:00Z',
},
]);
});
});
describe('when timezone is different', () => {
beforeAll(() => {
timezoneMock.register('US/Pacific');
});
afterAll(() => {
timezoneMock.unregister();
});
it('emits iso dates', () => {
return fillInputAndBlur('#custom-time-from', '2019-10-01 00:00:00')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19 12:00:00'))
.then(() => {
applyButtonElement().click();
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0]).toEqual([
{
start: '2019-10-01T07:00:00Z',
end: '2019-10-19T19:00:00Z',
},
]);
});
});
it('emits iso dates with utc format', () => {
wrapper.setProps({ utc: true });
return wrapper.vm
.$nextTick()
.then(() => fillInputAndBlur('#custom-time-from', '2019-10-01 00:00:00'))
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19 12:00:00'))
.then(() => {
applyButtonElement().click();
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0]).toEqual([
{
start: '2019-10-01T00:00:00Z',
end: '2019-10-19T12:00:00Z',
},
]);
});
});
});
});
it('unchecks quick range when text is input is clicked', done => {
it('unchecks quick range when text is input is clicked', () => {
const findActiveItems = () => findQuickRangeItems().filter(w => w.is('.active'));
expect(findActiveItems().length).toBe(1);
fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => {
expect(findActiveItems().length).toBe(0);
done();
})
.catch(done.fail);
return fillInputAndBlur('#custom-time-from', '2019-10-01').then(() => {
expect(findActiveItems().length).toBe(0);
});
});
it('emits dates in an object when a is clicked', () => {
@ -165,23 +248,22 @@ describe('DateTimePicker', () => {
.at(3) // any item
.trigger('click');
expect(dateTimePicker.emitted().input).toHaveLength(1);
expect(dateTimePicker.emitted().input[0][0]).toMatchObject({
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0][0]).toMatchObject({
duration: {
seconds: expect.any(Number),
},
});
});
it('hides the popover with cancel button', done => {
it('hides the popover with cancel button', () => {
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
return wrapper.vm.$nextTick(() => {
cancelButtonElement().click();
dateTimePicker.vm.$nextTick(() => {
return wrapper.vm.$nextTick(() => {
expect(dropdownMenu().classes('show')).toBe(false);
done();
});
});
});
@ -210,7 +292,7 @@ describe('DateTimePicker', () => {
jest.spyOn(Date, 'now').mockImplementation(() => MOCK_NOW);
});
it('renders dropdown with a label in the quick range', done => {
it('renders dropdown with a label in the quick range', () => {
createComponent({
value: {
duration: { seconds: 60 * 5 },
@ -218,14 +300,26 @@ describe('DateTimePicker', () => {
options: otherTimeRanges,
});
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
return wrapper.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe('5 minutes');
done();
});
});
it('renders dropdown with quick range items', done => {
it('renders dropdown with a label in the quick range and utc label', () => {
createComponent({
value: {
duration: { seconds: 60 * 5 },
},
utc: true,
options: otherTimeRanges,
});
dropdownToggle().trigger('click');
return wrapper.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe('5 minutes UTC');
});
});
it('renders dropdown with quick range items', () => {
createComponent({
value: {
duration: { seconds: 60 * 2 },
@ -233,7 +327,7 @@ describe('DateTimePicker', () => {
options: otherTimeRanges,
});
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
return wrapper.vm.$nextTick(() => {
const items = findQuickRangeItems();
expect(items.length).toBe(Object.keys(otherTimeRanges).length);
@ -245,22 +339,18 @@ describe('DateTimePicker', () => {
expect(items.at(2).text()).toBe('5 minutes');
expect(items.at(2).is('.active')).toBe(false);
done();
});
});
it('renders dropdown with a label not in the quick range', done => {
it('renders dropdown with a label not in the quick range', () => {
createComponent({
value: {
duration: { seconds: 60 * 4 },
},
});
dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => {
return wrapper.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe('2020-01-23 19:56:00 to 2020-01-23 20:00:00');
done();
});
});
});

View File

@ -21,7 +21,7 @@ RSpec.describe Quality::TestLevel do
context 'when level is unit' do
it 'returns a pattern' do
expect(subject.pattern(:unit))
.to eq("spec/{bin,channels,config,db,dependencies,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,support_specs,tasks,uploaders,validators,views,workers,elastic_integration}{,/**/}*_spec.rb")
.to eq("spec/{bin,channels,config,db,dependencies,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,support_specs,tasks,uploaders,validators,views,workers,elastic_integration,tooling}{,/**/}*_spec.rb")
end
end
@ -89,7 +89,7 @@ RSpec.describe Quality::TestLevel do
context 'when level is unit' do
it 'returns a regexp' do
expect(subject.regexp(:unit))
.to eq(%r{spec/(bin|channels|config|db|dependencies|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|support_specs|tasks|uploaders|validators|views|workers|elastic_integration)})
.to eq(%r{spec/(bin|channels|config|db|dependencies|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|support_specs|tasks|uploaders|validators|views|workers|elastic_integration|tooling)})
end
end
@ -144,6 +144,10 @@ RSpec.describe Quality::TestLevel do
expect(subject.level_for('spec/models/abuse_report_spec.rb')).to eq(:unit)
end
it 'returns the correct level for a tooling test' do
expect(subject.level_for('spec/tooling/lib/tooling/test_file_finder_spec.rb')).to eq(:unit)
end
it 'returns the correct level for a migration test' do
expect(subject.level_for('spec/migrations/add_default_and_free_plans_spec.rb')).to eq(:migration)
end

View File

@ -0,0 +1,95 @@
# frozen_string_literal: true
require 'spec_helper'
describe BlobViewer::MetricsDashboardYml do
include FakeBlobHelpers
include RepoHelpers
let_it_be(:project) { create(:project, :repository) }
let(:blob) { fake_blob(path: '.gitlab/dashboards/custom-dashboard.yml', data: data) }
let(:sha) { sample_commit.id }
subject(:viewer) { described_class.new(blob) }
context 'when the definition is valid' do
let(:data) { File.read(Rails.root.join('config/prometheus/common_metrics.yml')) }
describe '#valid?' do
it 'calls prepare! on the viewer' do
allow(PerformanceMonitoring::PrometheusDashboard).to receive(:from_json)
expect(viewer).to receive(:prepare!)
viewer.valid?
end
it 'returns true' do
expect(PerformanceMonitoring::PrometheusDashboard)
.to receive(:from_json).with(YAML.safe_load(data))
expect(viewer.valid?).to be_truthy
end
end
describe '#errors' do
it 'returns nil' do
allow(PerformanceMonitoring::PrometheusDashboard).to receive(:from_json)
expect(viewer.errors).to be nil
end
end
end
context 'when definition is invalid' do
let(:error) { ActiveModel::ValidationError.new(PerformanceMonitoring::PrometheusDashboard.new.tap(&:validate)) }
let(:data) do
<<~YAML
dashboard:
YAML
end
describe '#valid?' do
it 'returns false' do
expect(PerformanceMonitoring::PrometheusDashboard)
.to receive(:from_json).and_raise(error)
expect(viewer.valid?).to be_falsey
end
end
describe '#errors' do
it 'returns validation errors' do
allow(PerformanceMonitoring::PrometheusDashboard)
.to receive(:from_json).and_raise(error)
expect(viewer.errors).to be error.model.errors
end
end
end
context 'when YAML syntax is invalid' do
let(:data) do
<<~YAML
dashboard: 'empty metrics'
panel_groups:
- group: 'Group Title'
YAML
end
describe '#valid?' do
it 'returns false' do
expect(PerformanceMonitoring::PrometheusDashboard).not_to receive(:from_json)
expect(viewer.valid?).to be_falsey
end
end
describe '#errors' do
it 'returns validation errors' do
yaml_wrapped_errors = { 'YAML syntax': ["(<unknown>): did not find expected key while parsing a block mapping at line 1 column 1"] }
expect(viewer.errors).to be_kind_of ActiveModel::Errors
expect(viewer.errors.messages).to eql(yaml_wrapped_errors)
end
end
end
end

View File

@ -38,24 +38,123 @@ describe PerformanceMonitoring::PrometheusDashboard do
end
describe 'validations' do
context 'when dashboard is missing' do
before do
json_content['dashboard'] = nil
shared_examples 'validation failed' do |errors_messages|
it 'raises error with corresponding messages', :aggregate_failures do
expect { subject }.to raise_error do |error|
expect(error).to be_kind_of(ActiveModel::ValidationError)
expect(error.model.errors.messages).to eql(errors_messages)
end
end
subject { described_class.from_json(json_content) }
it { expect { subject }.to raise_error(ActiveModel::ValidationError) }
end
context 'when panel groups are missing' do
before do
json_content['panel_groups'] = []
context 'dashboard definition is missing panels_groups and dashboard keys' do
let(:json_content) do
{
"dashboard" => nil
}
end
subject { described_class.from_json(json_content) }
it_behaves_like 'validation failed', panel_groups: ["can't be blank"], dashboard: ["can't be blank"]
end
it { expect { subject }.to raise_error(ActiveModel::ValidationError) }
context 'group definition is missing panels and group keys' do
let(:json_content) do
{
"dashboard" => "Dashboard Title",
"templating" => {
"variables" => {
"variable1" => %w(value1 value2 value3)
}
},
"panel_groups" => [{ "group" => nil }]
}
end
it_behaves_like 'validation failed', panels: ["can't be blank"], group: ["can't be blank"]
end
context 'panel definition is missing metrics and title keys' do
let(:json_content) do
{
"dashboard" => "Dashboard Title",
"templating" => {
"variables" => {
"variable1" => %w(value1 value2 value3)
}
},
"panel_groups" => [{
"group" => "Group Title",
"panels" => [{
"type" => "area-chart",
"y_label" => "Y-Axis"
}]
}]
}
end
it_behaves_like 'validation failed', metrics: ["can't be blank"], title: ["can't be blank"]
end
context 'metrics definition is missing unit, query and query_range keys' do
let(:json_content) do
{
"dashboard" => "Dashboard Title",
"templating" => {
"variables" => {
"variable1" => %w(value1 value2 value3)
}
},
"panel_groups" => [{
"group" => "Group Title",
"panels" => [{
"type" => "area-chart",
"title" => "Chart Title",
"y_label" => "Y-Axis",
"metrics" => [{
"id" => "metric_of_ages",
"label" => "Metric of Ages",
"query_range" => nil
}]
}]
}]
}
end
it_behaves_like 'validation failed', unit: ["can't be blank"], query_range: ["can't be blank"], query: ["can't be blank"]
end
# for each parent entry validation first is done to its children,
# whole execution is stopped on first encountered error
# which is the one that is reported
context 'multiple offences on different levels' do
let(:json_content) do
{
"dashboard" => nil,
"panel_groups" => [{
"group" => nil,
"panels" => [{
"type" => "area-chart",
"title" => nil,
"y_label" => "Y-Axis",
"metrics" => [{
"id" => "metric_of_ages",
"label" => "Metric of Ages",
"query_range" => 'query'
}, {
"id" => "metric_of_ages",
"unit" => "count",
"label" => "Metric of Ages",
"query_range" => nil
}]
}]
}, {
"group" => 'group',
"panels" => nil
}]
}
end
it_behaves_like 'validation failed', unit: ["can't be blank"]
end
end
end

View File

@ -32,7 +32,7 @@ describe PerformanceMonitoring::PrometheusPanelGroup do
describe 'validations' do
context 'when group is missing' do
before do
json_content['group'] = nil
json_content.delete('group')
end
subject { described_class.from_json(json_content) }

View File

@ -54,7 +54,7 @@ describe PerformanceMonitoring::PrometheusPanel do
context 'when metrics are missing' do
before do
json_content['metrics'] = []
json_content.delete('metrics')
end
subject { described_class.from_json(json_content) }

View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'spec_helper'
describe ServiceFieldEntity do
let(:request) { double('request') }
subject { described_class.new(field, request: request, service: service).as_json }
before do
allow(request).to receive(:service).and_return(service)
end
describe '#as_json' do
context 'Jira Service' do
let(:service) { create(:jira_service) }
context 'field with type text' do
let(:field) { service.global_fields.find { |field| field[:name] == 'username' } }
it 'exposes correct attributes' do
expected_hash = {
type: 'text',
name: 'username',
title: 'Username or Email',
placeholder: 'Use a username for server version and an email for cloud version',
required: true,
choices: nil,
help: nil,
value: 'jira_username'
}
is_expected.to eq(expected_hash)
end
end
context 'field with type password' do
let(:field) { service.global_fields.find { |field| field[:name] == 'password' } }
it 'exposes correct attributes but hides password' do
expected_hash = {
type: 'password',
name: 'password',
title: 'Password or API token',
placeholder: 'Use a password for server version and an API token for cloud version',
required: true,
choices: nil,
help: nil,
value: 'true'
}
is_expected.to eq(expected_hash)
end
end
end
context 'EmailsOnPush Service' do
let(:service) { create(:emails_on_push_service) }
context 'field with type checkbox' do
let(:field) { service.global_fields.find { |field| field[:name] == 'send_from_committer_email' } }
it 'exposes correct attributes' do
expected_hash = {
type: 'checkbox',
name: 'send_from_committer_email',
title: 'Send from committer',
placeholder: nil,
required: nil,
choices: nil,
value: true
}
is_expected.to include(expected_hash)
expect(subject[:help]).to include("Send notifications from the committer's email address if the domain is part of the domain GitLab is running on")
end
end
context 'field with type select' do
let(:field) { service.global_fields.find { |field| field[:name] == 'branches_to_be_notified' } }
it 'exposes correct attributes' do
expected_hash = {
type: 'select',
name: 'branches_to_be_notified',
title: nil,
placeholder: nil,
required: nil,
choices: [['All branches', 'all'], ['Default branch', 'default'], ['Protected branches', 'protected'], ['Default branch and protected branches', 'default_and_protected']],
help: nil,
value: nil
}
is_expected.to eq(expected_hash)
end
end
end
end
end

View File

@ -782,10 +782,10 @@
eslint-plugin-vue "^6.2.1"
vue-eslint-parser "^7.0.0"
"@gitlab/svgs@1.133.0":
version "1.133.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.133.0.tgz#737fab044fc03cdb7690b5cf8c4f7a1a9222b43a"
integrity sha512-Briyqx73AuUafsmN3fj8nGIMG5jzA5gZtncs9UhnlgqA02oasdgBCNujD7cAUKDV2++sgFcMxkq90GTis+bkwQ==
"@gitlab/svgs@1.135.0":
version "1.135.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.135.0.tgz#b190f50c0a744d3b915f11defcdd17d7ae585928"
integrity sha512-ziNYtJ6NXk/XVbptKvgdyVqbYocisPK63oRvWldYDSi/H8IMpdBo0upe+VhjepoEzxuUZ1yxc8Y1JMJ+RTpM+w==
"@gitlab/ui@16.1.0":
version "16.1.0"