Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
6b7b853dff
commit
31a432e38a
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -103,6 +103,7 @@ export default {
|
|||
@keydown.meta.enter="submitForm"
|
||||
@keydown.ctrl.enter="submitForm"
|
||||
@keyup.esc.stop="cancelComment"
|
||||
@blur="$emit('onBlur')"
|
||||
>
|
||||
</textarea>
|
||||
</template>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) }"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {};
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -50,6 +50,7 @@ class Blob < SimpleDelegator
|
|||
BlobViewer::License,
|
||||
BlobViewer::Contributing,
|
||||
BlobViewer::Changelog,
|
||||
BlobViewer::MetricsDashboardYml,
|
||||
|
||||
BlobViewer::CargoToml,
|
||||
BlobViewer::Cartfile,
|
||||
|
|
|
@ -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
|
|
@ -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!)
|
||||
|
|
|
@ -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!)
|
||||
|
|
|
@ -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!)
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ServiceFieldSerializer < BaseSerializer
|
||||
entity ServiceFieldEntity
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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')
|
|
@ -0,0 +1,4 @@
|
|||
= icon('spinner spin fw')
|
||||
= _('Metrics Dashboard YAML definition') + '…'
|
||||
|
||||
= link_to _('Learn more'), help_page_path('user/project/integrations/prometheus.md')
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Improve header acccessibility
|
||||
merge_request: 33603
|
||||
author:
|
||||
type: fixed
|
|
@ -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 |
|
@ -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:
|
||||
|
|
|
@ -44,6 +44,7 @@ module Quality
|
|||
views
|
||||
workers
|
||||
elastic_integration
|
||||
tooling
|
||||
],
|
||||
integration: %w[
|
||||
controllers
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue