Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
1808454313
commit
c27acb1d37
2
Gemfile
2
Gemfile
|
@ -350,7 +350,7 @@ end
|
|||
group :development, :test do
|
||||
gem 'bullet', '~> 6.0.2', require: !!ENV['ENABLE_BULLET']
|
||||
gem 'pry-byebug', '~> 3.5.1', platform: :mri
|
||||
gem 'pry-rails', '~> 0.3.4'
|
||||
gem 'pry-rails', '~> 0.3.9'
|
||||
|
||||
gem 'awesome_print', require: false
|
||||
|
||||
|
|
|
@ -778,7 +778,7 @@ GEM
|
|||
pry-byebug (3.5.1)
|
||||
byebug (~> 9.1)
|
||||
pry (~> 0.10)
|
||||
pry-rails (0.3.6)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (4.0.3)
|
||||
pyu-ruby-sasl (0.0.3.3)
|
||||
|
@ -1323,7 +1323,7 @@ DEPENDENCIES
|
|||
premailer-rails (~> 1.10.3)
|
||||
prometheus-client-mmap (~> 0.10.0)
|
||||
pry-byebug (~> 3.5.1)
|
||||
pry-rails (~> 0.3.4)
|
||||
pry-rails (~> 0.3.9)
|
||||
rack (~> 2.0.7)
|
||||
rack-attack (~> 6.2.0)
|
||||
rack-cors (~> 1.0.0)
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<script>
|
||||
import { GlFormInputGroup, GlButton, GlIcon } from '@gitlab/ui';
|
||||
import { __ } from '~/locale';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlFormInputGroup,
|
||||
GlButton,
|
||||
GlIcon,
|
||||
},
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
optionValues: [
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
{ name: __('Embed'), value: `<script src='${this.url}.js'><\/script>` },
|
||||
{ name: __('Share'), value: this.url },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<gl-form-input-group
|
||||
id="embeddable-text"
|
||||
:predefined-options="optionValues"
|
||||
readonly
|
||||
select-on-click
|
||||
>
|
||||
<template #append>
|
||||
<gl-button new-style data-clipboard-target="#embeddable-text">
|
||||
<gl-icon name="copy-to-clipboard" :title="__('Copy')" />
|
||||
</gl-button>
|
||||
</template>
|
||||
</gl-form-input-group>
|
||||
</template>
|
|
@ -19,10 +19,10 @@ import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
|
|||
import { s__ } from '~/locale';
|
||||
import createFlash from '~/flash';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
|
||||
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
|
||||
import invalidUrl from '~/lib/utils/invalid_url';
|
||||
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||
import Icon from '~/vue_shared/components/icon.vue';
|
||||
import { getTimeRange } from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
|
||||
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
|
||||
|
||||
import GraphGroup from './graph_group.vue';
|
||||
|
@ -31,11 +31,8 @@ import GroupEmptyState from './group_empty_state.vue';
|
|||
import DashboardsDropdown from './dashboards_dropdown.vue';
|
||||
|
||||
import TrackEventDirective from '~/vue_shared/directives/track_event';
|
||||
import { getAddMetricTrackingOptions } from '../utils';
|
||||
|
||||
import { datePickerTimeWindows, metricStates } from '../constants';
|
||||
|
||||
const defaultTimeRange = getTimeRange();
|
||||
import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils';
|
||||
import { defaultTimeRange, timeRanges, metricStates } from '../constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -197,10 +194,9 @@ export default {
|
|||
return {
|
||||
state: 'gettingStarted',
|
||||
formIsValid: null,
|
||||
startDate: getParameterValues('start')[0] || defaultTimeRange.start,
|
||||
endDate: getParameterValues('end')[0] || defaultTimeRange.end,
|
||||
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
|
||||
hasValidDates: true,
|
||||
datePickerTimeWindows,
|
||||
timeRanges,
|
||||
isRearrangingPanels: false,
|
||||
};
|
||||
},
|
||||
|
@ -260,9 +256,11 @@ export default {
|
|||
if (!this.hasMetrics) {
|
||||
this.setGettingStartedEmptyState();
|
||||
} else {
|
||||
const { start, end } = convertToFixedRange(this.selectedTimeRange);
|
||||
|
||||
this.fetchData({
|
||||
start: this.startDate,
|
||||
end: this.endDate,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@ -287,8 +285,8 @@ export default {
|
|||
});
|
||||
},
|
||||
|
||||
onDateTimePickerApply(params) {
|
||||
redirectTo(mergeUrlParams(params, window.location.href));
|
||||
onDateTimePickerInput(timeRange) {
|
||||
redirectTo(timeRangeToUrl(timeRange));
|
||||
},
|
||||
onDateTimePickerInvalid() {
|
||||
createFlash(
|
||||
|
@ -296,8 +294,8 @@ export default {
|
|||
'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
|
||||
),
|
||||
);
|
||||
this.startDate = defaultTimeRange.start;
|
||||
this.endDate = defaultTimeRange.end;
|
||||
// As a fallback, switch to default time range instead
|
||||
this.selectedTimeRange = defaultTimeRange;
|
||||
},
|
||||
|
||||
generateLink(group, title, yLabel) {
|
||||
|
@ -447,10 +445,9 @@ export default {
|
|||
>
|
||||
<date-time-picker
|
||||
ref="dateTimePicker"
|
||||
:start="startDate"
|
||||
:end="endDate"
|
||||
:time-windows="datePickerTimeWindows"
|
||||
@apply="onDateTimePickerApply"
|
||||
:value="selectedTimeRange"
|
||||
:options="timeRanges"
|
||||
@input="onDateTimePickerInput"
|
||||
@invalid="onDateTimePickerInvalid"
|
||||
/>
|
||||
</gl-form-group>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<script>
|
||||
import { mapActions, mapState, mapGetters } from 'vuex';
|
||||
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
|
||||
import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
|
||||
import { sidebarAnimationDuration } from '../constants';
|
||||
import { getTimeRange } from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
|
||||
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||
import { timeRangeFromUrl, removeTimeRangeParams } from '../utils';
|
||||
import { sidebarAnimationDuration, defaultTimeRange } from '../constants';
|
||||
|
||||
let sidebarMutationObserver;
|
||||
|
||||
|
@ -18,10 +18,8 @@ export default {
|
|||
},
|
||||
},
|
||||
data() {
|
||||
const defaultRange = getTimeRange();
|
||||
const start = getParameterValues('start', this.dashboardUrl)[0] || defaultRange.start;
|
||||
const end = getParameterValues('end', this.dashboardUrl)[0] || defaultRange.end;
|
||||
|
||||
const timeRange = timeRangeFromUrl(this.dashboardUrl) || defaultTimeRange;
|
||||
const { start, end } = convertToFixedRange(timeRange);
|
||||
const params = {
|
||||
start,
|
||||
end,
|
||||
|
@ -81,7 +79,7 @@ export default {
|
|||
},
|
||||
setInitialState() {
|
||||
this.setEndpoints({
|
||||
dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl),
|
||||
dashboardEndpoint: removeTimeRangeParams(this.dashboardUrl),
|
||||
});
|
||||
this.setShowErrorBanner(false);
|
||||
},
|
||||
|
|
|
@ -83,34 +83,36 @@ export const dateFormats = {
|
|||
default: 'dd mmm yyyy, h:MMTT',
|
||||
};
|
||||
|
||||
export const datePickerTimeWindows = {
|
||||
thirtyMinutes: {
|
||||
export const timeRanges = [
|
||||
{
|
||||
label: __('30 minutes'),
|
||||
seconds: 60 * 30,
|
||||
duration: { seconds: 60 * 30 },
|
||||
},
|
||||
threeHours: {
|
||||
{
|
||||
label: __('3 hours'),
|
||||
seconds: 60 * 60 * 3,
|
||||
duration: { seconds: 60 * 60 * 3 },
|
||||
},
|
||||
eightHours: {
|
||||
{
|
||||
label: __('8 hours'),
|
||||
seconds: 60 * 60 * 8,
|
||||
duration: { seconds: 60 * 60 * 8 },
|
||||
default: true,
|
||||
},
|
||||
oneDay: {
|
||||
{
|
||||
label: __('1 day'),
|
||||
seconds: 60 * 60 * 24 * 1,
|
||||
duration: { seconds: 60 * 60 * 24 * 1 },
|
||||
},
|
||||
threeDays: {
|
||||
{
|
||||
label: __('3 days'),
|
||||
seconds: 60 * 60 * 24 * 3,
|
||||
duration: { seconds: 60 * 60 * 24 * 3 },
|
||||
},
|
||||
oneWeek: {
|
||||
{
|
||||
label: __('1 week'),
|
||||
seconds: 60 * 60 * 24 * 7 * 1,
|
||||
duration: { seconds: 60 * 60 * 24 * 7 * 1 },
|
||||
},
|
||||
twoWeeks: {
|
||||
{
|
||||
label: __('2 weeks'),
|
||||
seconds: 60 * 60 * 24 * 7 * 2,
|
||||
duration: { seconds: 60 * 60 * 24 * 7 * 2 },
|
||||
},
|
||||
};
|
||||
];
|
||||
|
||||
export const defaultTimeRange = timeRanges.find(tr => tr.default);
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import GetSnippetQuery from '../queries/snippet.query.graphql';
|
||||
import SnippetHeader from './snippet_header.vue';
|
||||
import SnippetTitle from './snippet_title.vue';
|
||||
import SnippetBlob from './snippet_blob_view.vue';
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
|
||||
export default {
|
||||
|
@ -9,6 +10,7 @@ export default {
|
|||
SnippetHeader,
|
||||
SnippetTitle,
|
||||
GlLoadingIcon,
|
||||
SnippetBlob,
|
||||
},
|
||||
apollo: {
|
||||
snippet: {
|
||||
|
@ -50,6 +52,7 @@ export default {
|
|||
<template v-else>
|
||||
<snippet-header :snippet="snippet" />
|
||||
<snippet-title :snippet="snippet" />
|
||||
<snippet-blob :snippet="snippet" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<script>
|
||||
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
|
||||
import { SNIPPET_VISIBILITY_PUBLIC } from '../constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BlobEmbeddable,
|
||||
},
|
||||
props: {
|
||||
snippet: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
embeddable() {
|
||||
return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" />
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,3 @@
|
|||
export const SNIPPET_VISIBILITY_PRIVATE = 'private';
|
||||
export const SNIPPET_VISIBILITY_INTERNAL = 'internal';
|
||||
export const SNIPPET_VISIBILITY_PUBLIC = 'public';
|
|
@ -12,8 +12,7 @@
|
|||
* css-class="btn-transparent"
|
||||
* />
|
||||
*/
|
||||
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
|
||||
import Icon from '../components/icon.vue';
|
||||
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
|
||||
|
||||
export default {
|
||||
name: 'ClipboardButton',
|
||||
|
@ -22,7 +21,7 @@ export default {
|
|||
},
|
||||
components: {
|
||||
GlButton,
|
||||
Icon,
|
||||
GlIcon,
|
||||
},
|
||||
props: {
|
||||
text: {
|
||||
|
@ -72,6 +71,6 @@ export default {
|
|||
:title="title"
|
||||
:data-clipboard-text="clipboardText"
|
||||
>
|
||||
<icon name="duplicate" />
|
||||
<gl-icon name="copy-to-clipboard" />
|
||||
</gl-button>
|
||||
</template>
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
<script>
|
||||
import { GlButton, 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 DateTimePickerInput from './date_time_picker_input.vue';
|
||||
import {
|
||||
defaultTimeWindows,
|
||||
defaultTimeRanges,
|
||||
defaultTimeRange,
|
||||
isValidDate,
|
||||
getTimeRange,
|
||||
getTimeWindowKey,
|
||||
stringToISODate,
|
||||
ISODateToString,
|
||||
truncateZerosInDateTime,
|
||||
|
@ -15,7 +17,7 @@ import {
|
|||
} from './date_time_picker_lib';
|
||||
|
||||
const events = {
|
||||
apply: 'apply',
|
||||
input: 'input',
|
||||
invalid: 'invalid',
|
||||
};
|
||||
|
||||
|
@ -29,24 +31,22 @@ export default {
|
|||
GlDropdownItem,
|
||||
},
|
||||
props: {
|
||||
start: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
timeWindows: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => defaultTimeWindows,
|
||||
default: () => defaultTimeRange,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => defaultTimeRanges,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
startDate: this.start,
|
||||
endDate: this.end,
|
||||
timeRange: this.value,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -67,6 +67,7 @@ export default {
|
|||
set(val) {
|
||||
// Attempt to set a formatted date if possible
|
||||
this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
|
||||
this.timeRange = null;
|
||||
},
|
||||
},
|
||||
endInput: {
|
||||
|
@ -76,23 +77,48 @@ export default {
|
|||
set(val) {
|
||||
// Attempt to set a formatted date if possible
|
||||
this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
|
||||
this.timeRange = null;
|
||||
},
|
||||
},
|
||||
|
||||
timeWindowText() {
|
||||
const timeWindow = getTimeWindowKey({ start: this.start, end: this.end }, this.timeWindows);
|
||||
if (timeWindow) {
|
||||
return this.timeWindows[timeWindow].label;
|
||||
} else if (isValidDate(this.start) && isValidDate(this.end)) {
|
||||
return sprintf(__('%{start} to %{end}'), {
|
||||
start: this.formatDate(this.start),
|
||||
end: this.formatDate(this.end),
|
||||
});
|
||||
try {
|
||||
const timeRange = findTimeRange(this.value, this.options);
|
||||
if (timeRange) {
|
||||
return timeRange.label;
|
||||
}
|
||||
|
||||
const { start, end } = convertToFixedRange(this.value);
|
||||
if (isValidDate(start) && isValidDate(end)) {
|
||||
return sprintf(__('%{start} to %{end}'), {
|
||||
start: this.formatDate(start),
|
||||
end: this.formatDate(end),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return __('Invalid date range');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value(newValue) {
|
||||
const { start, end } = convertToFixedRange(newValue);
|
||||
this.timeRange = this.value;
|
||||
this.startDate = start;
|
||||
this.endDate = end;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
try {
|
||||
const { start, end } = convertToFixedRange(this.timeRange);
|
||||
this.startDate = start;
|
||||
this.endDate = end;
|
||||
} catch {
|
||||
// when dates cannot be parsed, emit error.
|
||||
this.$emit(events.invalid);
|
||||
}
|
||||
|
||||
// Validate on mounted, and trigger an update if needed
|
||||
if (!this.isValid) {
|
||||
this.$emit(events.invalid);
|
||||
|
@ -102,21 +128,22 @@ export default {
|
|||
formatDate(date) {
|
||||
return truncateZerosInDateTime(ISODateToString(date));
|
||||
},
|
||||
setTimeWindow(key) {
|
||||
const { start, end } = getTimeRange(key, this.timeWindows);
|
||||
this.startDate = start;
|
||||
this.endDate = end;
|
||||
|
||||
this.apply();
|
||||
},
|
||||
closeDropdown() {
|
||||
this.$refs.dropdown.hide();
|
||||
},
|
||||
apply() {
|
||||
this.$emit(events.apply, {
|
||||
isOptionActive(option) {
|
||||
return isEqualTimeRanges(option, this.timeRange);
|
||||
},
|
||||
setQuickRange(option) {
|
||||
this.timeRange = option;
|
||||
this.$emit(events.input, this.timeRange);
|
||||
},
|
||||
setFixedRange() {
|
||||
this.timeRange = convertToFixedRange({
|
||||
start: this.startDate,
|
||||
end: this.endDate,
|
||||
});
|
||||
this.$emit(events.input, this.timeRange);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -146,7 +173,7 @@ export default {
|
|||
</div>
|
||||
<gl-form-group>
|
||||
<gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
|
||||
<gl-button variant="success" :disabled="!isValid" @click="apply()">
|
||||
<gl-button variant="success" :disabled="!isValid" @click="setFixedRange()">
|
||||
{{ __('Apply') }}
|
||||
</gl-button>
|
||||
</gl-form-group>
|
||||
|
@ -155,19 +182,20 @@ export default {
|
|||
<template #label>
|
||||
<span class="gl-pl-5">{{ __('Quick range') }}</span>
|
||||
</template>
|
||||
|
||||
<gl-dropdown-item
|
||||
v-for="(timeWindow, key) in timeWindows"
|
||||
:key="key"
|
||||
:active="timeWindow.label === timeWindowText"
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
:active="isOptionActive(option)"
|
||||
active-class="active"
|
||||
@click="setTimeWindow(key)"
|
||||
@click="setQuickRange(option)"
|
||||
>
|
||||
<icon
|
||||
name="mobile-issue-close"
|
||||
class="align-bottom"
|
||||
:class="{ invisible: timeWindow.label !== timeWindowText }"
|
||||
:class="{ invisible: !isOptionActive(option) }"
|
||||
/>
|
||||
{{ timeWindow.label }}
|
||||
{{ option.label }}
|
||||
</gl-dropdown-item>
|
||||
</gl-form-group>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import dateformat from 'dateformat';
|
||||
import { __ } from '~/locale';
|
||||
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
|
||||
|
||||
/**
|
||||
* Valid strings for this regex are
|
||||
|
@ -9,37 +8,30 @@ import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
|
|||
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]))?$/;
|
||||
|
||||
/**
|
||||
* A key-value pair of "time windows".
|
||||
*
|
||||
* A time window is a representation of period of time that starts
|
||||
* some time in past until now. Keys are only used for easy reference.
|
||||
*
|
||||
* It is represented as user friendly `label` and number of `seconds`
|
||||
* to be substracted from now.
|
||||
* Default time ranges for the date picker.
|
||||
* @see app/assets/javascripts/lib/utils/datetime_range.js
|
||||
*/
|
||||
export const defaultTimeWindows = {
|
||||
thirtyMinutes: {
|
||||
export const defaultTimeRanges = [
|
||||
{
|
||||
duration: { seconds: 60 * 30 },
|
||||
label: __('30 minutes'),
|
||||
seconds: 60 * 30,
|
||||
},
|
||||
threeHours: {
|
||||
{
|
||||
duration: { seconds: 60 * 60 * 3 },
|
||||
label: __('3 hours'),
|
||||
seconds: 60 * 60 * 3,
|
||||
},
|
||||
eightHours: {
|
||||
{
|
||||
duration: { seconds: 60 * 60 * 8 },
|
||||
label: __('8 hours'),
|
||||
seconds: 60 * 60 * 8,
|
||||
default: true,
|
||||
},
|
||||
oneDay: {
|
||||
{
|
||||
duration: { seconds: 60 * 60 * 24 * 1 },
|
||||
label: __('1 day'),
|
||||
seconds: 60 * 60 * 24 * 1,
|
||||
},
|
||||
threeDays: {
|
||||
label: __('3 days'),
|
||||
seconds: 60 * 60 * 24 * 3,
|
||||
},
|
||||
};
|
||||
];
|
||||
|
||||
export const defaultTimeRange = defaultTimeRanges.find(tr => tr.default);
|
||||
|
||||
export const dateFormats = {
|
||||
ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
|
||||
|
@ -67,46 +59,6 @@ export const isValidDate = dateString => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* For a given time window key (e.g. `threeHours`) and key-value pair
|
||||
* object of time windows.
|
||||
*
|
||||
* Returns a date time range with start and end.
|
||||
*
|
||||
* @param {String} timeWindowKey - A key in the object of time windows.
|
||||
* @param {Object} timeWindows - A key-value pair of time windows,
|
||||
* with a second duration and a label.
|
||||
* @returns An object with time range, start and end dates, in ISO format.
|
||||
*/
|
||||
export const getTimeRange = (timeWindowKey, timeWindows = defaultTimeWindows) => {
|
||||
let difference;
|
||||
if (timeWindows[timeWindowKey]) {
|
||||
difference = timeWindows[timeWindowKey].seconds;
|
||||
} else {
|
||||
const [defaultEntry] = Object.entries(timeWindows).filter(
|
||||
([, timeWindow]) => timeWindow.default,
|
||||
);
|
||||
// find default time window
|
||||
difference = defaultEntry[1].seconds;
|
||||
}
|
||||
|
||||
const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
|
||||
const start = end - difference;
|
||||
|
||||
return {
|
||||
start: new Date(secondsToMilliseconds(start)).toISOString(),
|
||||
end: new Date(secondsToMilliseconds(end)).toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const getTimeWindowKey = ({ start, end }, timeWindows = defaultTimeWindows) =>
|
||||
Object.entries(timeWindows).reduce((acc, [timeWindowKey, timeWindow]) => {
|
||||
if (new Date(end) - new Date(start) === secondsToMilliseconds(timeWindow.seconds)) {
|
||||
return timeWindowKey;
|
||||
}
|
||||
return acc;
|
||||
}, null);
|
||||
|
||||
/**
|
||||
* Convert the input in Time picker component to ISO date.
|
||||
*
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Allow for relative time ranges in metrics dashboard URLs
|
||||
merge_request: 23765
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Remove unused Code Hotspots database tables
|
||||
merge_request: 23590
|
||||
author:
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix visibility levels of subgroups to be not higher than their parents' level
|
||||
merge_request: 22889
|
||||
author:
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Updated icon for copy-to-clipboard button
|
||||
merge_request: 24146
|
||||
author:
|
||||
type: other
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RemoveAnalyticsRepositoryTableFksOnProjects < ActiveRecord::Migration[5.2]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
def up
|
||||
with_lock_retries do
|
||||
# Requires ExclusiveLock on all tables. analytics_* tables are empty
|
||||
remove_foreign_key :analytics_repository_files, :projects
|
||||
remove_foreign_key :analytics_repository_file_edits, :projects
|
||||
remove_foreign_key :analytics_repository_file_commits, :projects
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
with_lock_retries do
|
||||
# rubocop:disable Migration/AddConcurrentForeignKey
|
||||
add_foreign_key :analytics_repository_files, :projects, on_delete: :cascade
|
||||
add_foreign_key :analytics_repository_file_edits, :projects, on_delete: :cascade
|
||||
add_foreign_key :analytics_repository_file_commits, :projects, on_delete: :cascade
|
||||
# rubocop:enable Migration/AddConcurrentForeignKey
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RemoveAnalyticsRepositoryFilesFkOnOtherAnalyticsTables < ActiveRecord::Migration[5.2]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
def up
|
||||
with_lock_retries do
|
||||
# Requires ExclusiveLock on all tables. analytics_* tables are empty
|
||||
remove_foreign_key :analytics_repository_file_edits, :analytics_repository_files
|
||||
remove_foreign_key :analytics_repository_file_commits, :analytics_repository_files
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
with_lock_retries do
|
||||
# rubocop:disable Migration/AddConcurrentForeignKey
|
||||
add_foreign_key :analytics_repository_file_edits, :analytics_repository_files, on_delete: :cascade
|
||||
add_foreign_key :analytics_repository_file_commits, :analytics_repository_files, on_delete: :cascade
|
||||
# rubocop:enable Migration/AddConcurrentForeignKey
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DropAnalyticsRepositoryFilesTable < ActiveRecord::Migration[5.2]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
def up
|
||||
# Requires ExclusiveLock on the table. Not in use, no records, no FKs.
|
||||
drop_table :analytics_repository_files
|
||||
end
|
||||
|
||||
def down
|
||||
create_table :analytics_repository_files do |t|
|
||||
t.bigint :project_id, null: false
|
||||
t.string :file_path, limit: 4096, null: false
|
||||
end
|
||||
|
||||
add_index :analytics_repository_files, [:project_id, :file_path], unique: true
|
||||
end
|
||||
end
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DropAnalyticsRepositoryFileCommitsTable < ActiveRecord::Migration[5.2]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
def up
|
||||
# Requires ExclusiveLock on the table. Not in use, no records, no FKs.
|
||||
drop_table :analytics_repository_file_commits
|
||||
end
|
||||
|
||||
def down
|
||||
create_table :analytics_repository_file_commits do |t|
|
||||
t.bigint :analytics_repository_file_id, null: false
|
||||
t.index :analytics_repository_file_id, name: 'index_analytics_repository_file_commits_file_id'
|
||||
t.bigint :project_id, null: false
|
||||
t.date :committed_date, null: false
|
||||
t.integer :commit_count, limit: 2, null: false
|
||||
end
|
||||
|
||||
add_index :analytics_repository_file_commits,
|
||||
[:project_id, :committed_date, :analytics_repository_file_id],
|
||||
name: 'index_file_commits_on_committed_date_file_id_and_project_id',
|
||||
unique: true
|
||||
end
|
||||
end
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DropAnalyticsRepositoryFileEditsTable < ActiveRecord::Migration[5.2]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
def up
|
||||
# Requires ExclusiveLock on the table. Not in use, no records, no FKs.
|
||||
drop_table :analytics_repository_file_edits
|
||||
end
|
||||
|
||||
def down
|
||||
create_table :analytics_repository_file_edits do |t|
|
||||
t.bigint :project_id, null: false
|
||||
t.index :project_id
|
||||
t.bigint :analytics_repository_file_id, null: false
|
||||
t.date :committed_date, null: false
|
||||
t.integer :num_edits, null: false, default: 0
|
||||
end
|
||||
|
||||
add_index :analytics_repository_file_edits,
|
||||
[:analytics_repository_file_id, :committed_date, :project_id],
|
||||
name: 'index_file_edits_on_committed_date_file_id_and_project_id',
|
||||
unique: true
|
||||
end
|
||||
end
|
|
@ -0,0 +1,56 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class ScheduleUpdateExistingSubgroupToMatchVisibilityLevelOfParent < ActiveRecord::Migration[5.2]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
MIGRATION = 'UpdateExistingSubgroupToMatchVisibilityLevelOfParent'
|
||||
DELAY_INTERVAL = 5.minutes.to_i
|
||||
BATCH_SIZE = 1000
|
||||
VISIBILITY_LEVELS = {
|
||||
internal: 10,
|
||||
private: 0
|
||||
}
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
offset = update_groups(VISIBILITY_LEVELS[:internal])
|
||||
update_groups(VISIBILITY_LEVELS[:private], offset: offset)
|
||||
end
|
||||
|
||||
def down
|
||||
# no-op
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_groups(level, offset: 0)
|
||||
groups = exec_query <<~SQL
|
||||
SELECT id
|
||||
FROM namespaces
|
||||
WHERE visibility_level = #{level}
|
||||
AND type = 'Group'
|
||||
AND EXISTS (SELECT 1
|
||||
FROM namespaces AS children
|
||||
WHERE children.parent_id = namespaces.id)
|
||||
SQL
|
||||
|
||||
ids = groups.rows.flatten
|
||||
|
||||
iterator = 1
|
||||
|
||||
ids.in_groups_of(BATCH_SIZE, false) do |batch_of_ids|
|
||||
delay = DELAY_INTERVAL * (iterator + offset)
|
||||
BackgroundMigrationWorker.perform_in(delay, MIGRATION, [batch_of_ids, level])
|
||||
iterator += 1
|
||||
end
|
||||
|
||||
say("Background jobs for visibility level #{level} scheduled in #{iterator} iterations")
|
||||
|
||||
offset + iterator
|
||||
end
|
||||
end
|
29
db/schema.rb
29
db/schema.rb
|
@ -94,30 +94,6 @@ ActiveRecord::Schema.define(version: 2020_01_27_090233) do
|
|||
t.index ["project_id"], name: "analytics_repository_languages_on_project_id"
|
||||
end
|
||||
|
||||
create_table "analytics_repository_file_commits", force: :cascade do |t|
|
||||
t.bigint "analytics_repository_file_id", null: false
|
||||
t.bigint "project_id", null: false
|
||||
t.date "committed_date", null: false
|
||||
t.integer "commit_count", limit: 2, null: false
|
||||
t.index ["analytics_repository_file_id"], name: "index_analytics_repository_file_commits_file_id"
|
||||
t.index ["project_id", "committed_date", "analytics_repository_file_id"], name: "index_file_commits_on_committed_date_file_id_and_project_id", unique: true
|
||||
end
|
||||
|
||||
create_table "analytics_repository_file_edits", force: :cascade do |t|
|
||||
t.bigint "project_id", null: false
|
||||
t.bigint "analytics_repository_file_id", null: false
|
||||
t.date "committed_date", null: false
|
||||
t.integer "num_edits", default: 0, null: false
|
||||
t.index ["analytics_repository_file_id", "committed_date", "project_id"], name: "index_file_edits_on_committed_date_file_id_and_project_id", unique: true
|
||||
t.index ["project_id"], name: "index_analytics_repository_file_edits_on_project_id"
|
||||
end
|
||||
|
||||
create_table "analytics_repository_files", force: :cascade do |t|
|
||||
t.bigint "project_id", null: false
|
||||
t.string "file_path", limit: 4096, null: false
|
||||
t.index ["project_id", "file_path"], name: "index_analytics_repository_files_on_project_id_and_file_path", unique: true
|
||||
end
|
||||
|
||||
create_table "appearances", id: :serial, force: :cascade do |t|
|
||||
t.string "title", null: false
|
||||
t.text "description", null: false
|
||||
|
@ -4476,11 +4452,6 @@ ActiveRecord::Schema.define(version: 2020_01_27_090233) do
|
|||
add_foreign_key "analytics_cycle_analytics_project_stages", "projects", on_delete: :cascade
|
||||
add_foreign_key "analytics_language_trend_repository_languages", "programming_languages", on_delete: :cascade
|
||||
add_foreign_key "analytics_language_trend_repository_languages", "projects", on_delete: :cascade
|
||||
add_foreign_key "analytics_repository_file_commits", "analytics_repository_files", on_delete: :cascade
|
||||
add_foreign_key "analytics_repository_file_commits", "projects", on_delete: :cascade
|
||||
add_foreign_key "analytics_repository_file_edits", "analytics_repository_files", on_delete: :cascade
|
||||
add_foreign_key "analytics_repository_file_edits", "projects", on_delete: :cascade
|
||||
add_foreign_key "analytics_repository_files", "projects", on_delete: :cascade
|
||||
add_foreign_key "application_settings", "namespaces", column: "custom_project_templates_group_id", on_delete: :nullify
|
||||
add_foreign_key "application_settings", "namespaces", column: "instance_administrators_group_id", name: "fk_e8a145f3a7", on_delete: :nullify
|
||||
add_foreign_key "application_settings", "projects", column: "file_template_project_id", name: "fk_ec757bd087", on_delete: :nullify
|
||||
|
|
|
@ -1062,17 +1062,36 @@ a helpful link back to how the feature was developed.
|
|||
> [Introduced](<link-to-issue>) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.3.
|
||||
```
|
||||
|
||||
### Removing version text
|
||||
### Importance of referencing GitLab versions and tiers
|
||||
|
||||
Over time, version text will reference a progressively older version of GitLab. In cases where version text
|
||||
refers to versions of GitLab four or more major versions back, consider removing the text.
|
||||
Mentioning GitLab versions and tiers is important to all users and contributors
|
||||
to quickly have access to the issue or merge request that
|
||||
introduced the change for reference. Also, they can easily understand what
|
||||
features they have in their GitLab instance and version, given that the note has
|
||||
some key information.
|
||||
|
||||
`[Introduced](link-to-issue) in [GitLab Premium](https://about.gitlab.com/pricing) 12.7`
|
||||
links to the issue that introduced the feature, says which GitLab tier it
|
||||
belongs to, says the GitLab version that it became available in, and links to
|
||||
the pricing page in case the user wants to upgrade to a paid tier
|
||||
to use that feature.
|
||||
|
||||
For example, if I'm a regular user and I'm looking at the docs for a feature I haven't used before,
|
||||
I can immediately see if that feature is available to me or not. Alternatively,
|
||||
if I have been using a certain feature for a long time and it changed in some way,
|
||||
it's important
|
||||
to me to spot when it changed and what's new in that feature.
|
||||
|
||||
This is even more important as we don't have a perfect process for shipping docs.
|
||||
Unfortunately, we still see features without docs and docs without
|
||||
features. So, for now, we cannot rely 100% on the docs site versions.
|
||||
|
||||
Over time, version text will reference a progressively older version of GitLab.
|
||||
In cases where version text refers to versions of GitLab four or more major
|
||||
versions back, you can consider removing the text if it's irrelevant or confusing.
|
||||
|
||||
For example, if the current major version is 12.x, version text referencing versions of GitLab 8.x
|
||||
and older are candidates for removal.
|
||||
|
||||
NOTE: **Note:**
|
||||
This guidance applies to any text that mentions a GitLab version, not just "Introduced in... " text.
|
||||
Other text includes deprecation notices and version-specific how-to information.
|
||||
and older are candidates for removal if necessary for clearer or cleaner docs.
|
||||
|
||||
## Product badges
|
||||
|
||||
|
@ -1103,6 +1122,8 @@ The tier should be ideally added to headers, so that the full badge will be disp
|
|||
However, it can be also mentioned from paragraphs, list items, and table cells. For these cases,
|
||||
the tier mention will be represented by an orange question mark that will show the tiers on hover.
|
||||
|
||||
Use the lowest tier at the page level, even if higher-level tiers exist on the page. For example, you might have a page that is marked as Starter but a section badged as Premium.
|
||||
|
||||
For example:
|
||||
|
||||
- `**(STARTER)**` renders as **(STARTER)**
|
||||
|
|
|
@ -174,14 +174,14 @@ sequenceDiagram
|
|||
c ->>+w: POST /some/url/upload
|
||||
|
||||
w->>+s: save the incoming file on a temporary location
|
||||
s-->>-w:
|
||||
s-->>-w: request result
|
||||
|
||||
w->>+r: POST /some/url/upload
|
||||
Note over w,r: file was replaced with its location<br>and other metadata
|
||||
|
||||
opt requires async processing
|
||||
r->>+redis: schedule a job
|
||||
redis-->>-r:
|
||||
redis-->>-r: job is scheduled
|
||||
end
|
||||
|
||||
r-->>-c: request result
|
||||
|
@ -208,9 +208,11 @@ This is the more advanced acceleration technique we have in place.
|
|||
|
||||
Workhorse asks rails for temporary pre-signed object storage URLs and directly uploads to object storage.
|
||||
|
||||
In this setup an extra rails route needs to be implemented in order to handle authorization,
|
||||
you can see an example of this in [`Projects::LfsStorageController`](https://gitlab.com/gitlab-org/gitlab/blob/cc723071ad337573e0360a879cbf99bc4fb7adb9/app/controllers/projects/lfs_storage_controller.rb)
|
||||
and [its routes](https://gitlab.com/gitlab-org/gitlab/blob/cc723071ad337573e0360a879cbf99bc4fb7adb9/config/routes/git_http.rb#L31-32).
|
||||
In this setup, an extra Rails route must be implemented in order to handle authorization. Examples of this can be found in:
|
||||
|
||||
- [`Projects::LfsStorageController`](https://gitlab.com/gitlab-org/gitlab/blob/cc723071ad337573e0360a879cbf99bc4fb7adb9/app/controllers/projects/lfs_storage_controller.rb)
|
||||
and [its routes](https://gitlab.com/gitlab-org/gitlab/blob/cc723071ad337573e0360a879cbf99bc4fb7adb9/config/routes/git_http.rb#L31-32).
|
||||
- [API endpoints for uploading packages](packages.md#file-uploads).
|
||||
|
||||
**note:** this will fallback to _disk buffered upload_ when `direct_upload` is disabled inside the [object storage setting](../administration/uploads.md#object-storage-settings).
|
||||
The answer to the `/authorize` call will only contain a file system path.
|
||||
|
@ -231,17 +233,17 @@ sequenceDiagram
|
|||
|
||||
w->>+os: PUT file
|
||||
Note over w,os: file is stored on a temporary location. Rails select the destination
|
||||
os-->>-w:
|
||||
os-->>-w: request result
|
||||
|
||||
w->>+r: POST /some/url/upload
|
||||
Note over w,r: file was replaced with its location<br>and other metadata
|
||||
|
||||
r->>+os: move object to final destination
|
||||
os-->>-r:
|
||||
os-->>-r: request result
|
||||
|
||||
opt requires async processing
|
||||
r->>+redis: schedule a job
|
||||
redis-->>-r:
|
||||
redis-->>-r: job is scheduled
|
||||
end
|
||||
|
||||
r-->>-c: request result
|
||||
|
|
|
@ -9,4 +9,7 @@ You can import your existing repositories by providing the Git URL:
|
|||
1. Click **Create project** to begin the import process
|
||||
1. Once complete, you will be redirected to your newly created project
|
||||
|
||||
NOTE: **Note:**
|
||||
If your password has special characters, you will need to enter them URL encoded, please see the [GitLab issue](https://gitlab.com/gitlab-org/gitlab/issues/29952) for more information.
|
||||
|
||||
![Import project by repo URL](img/import_projects_from_repo_url.png)
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module BackgroundMigration
|
||||
# This background migration updates children of group to match visibility of a parent
|
||||
class UpdateExistingSubgroupToMatchVisibilityLevelOfParent
|
||||
def perform(parents_groups_ids, level)
|
||||
groups_ids = Gitlab::ObjectHierarchy.new(Group.where(id: parents_groups_ids))
|
||||
.base_and_descendants
|
||||
.where("visibility_level > ?", level)
|
||||
.select(:id)
|
||||
|
||||
return if groups_ids.empty?
|
||||
|
||||
Group
|
||||
.where(id: groups_ids)
|
||||
.update_all(visibility_level: level)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -147,11 +147,11 @@ module Gitlab
|
|||
end
|
||||
|
||||
def current_lock_timeout_in_ms
|
||||
timing_configuration[current_iteration - 1][0].in_milliseconds
|
||||
Integer(timing_configuration[current_iteration - 1][0].in_milliseconds)
|
||||
end
|
||||
|
||||
def current_sleep_time_in_seconds
|
||||
timing_configuration[current_iteration - 1][1].to_i
|
||||
timing_configuration[current_iteration - 1][1].to_f
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -10431,6 +10431,9 @@ msgstr ""
|
|||
msgid "Invalid date format. Please use UTC format as YYYY-MM-DD"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid date range"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid feature"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
|
||||
import { GlFormInputGroup } from '@gitlab/ui';
|
||||
|
||||
describe('Blob Embeddable', () => {
|
||||
let wrapper;
|
||||
const url = 'https://foo.bar';
|
||||
|
||||
function createComponent() {
|
||||
wrapper = shallowMount(BlobEmbeddable, {
|
||||
propsData: {
|
||||
url,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders gl-form-input-group component', () => {
|
||||
expect(wrapper.find(GlFormInputGroup).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('makes up optionValues based on the url prop', () => {
|
||||
expect(wrapper.vm.optionValues).toEqual([
|
||||
{ name: 'Embed', value: expect.stringContaining(`${url}.js`) },
|
||||
{ name: 'Share', value: url },
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -69,9 +69,8 @@ exports[`Dashboard template matches the default snapshot 1`] = `
|
|||
label-size="sm"
|
||||
>
|
||||
<date-time-picker-stub
|
||||
end="2020-01-01T18:57:47.000Z"
|
||||
start="2020-01-01T18:27:47.000Z"
|
||||
timewindows="[object Object]"
|
||||
options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
|
||||
value="[object Object]"
|
||||
/>
|
||||
</gl-form-group-stub>
|
||||
|
||||
|
|
|
@ -5,13 +5,7 @@ import Dashboard from '~/monitoring/components/dashboard.vue';
|
|||
import { createStore } from '~/monitoring/stores';
|
||||
import { propsData } from '../init_utils';
|
||||
|
||||
jest.mock('~/lib/utils/url_utility', () => ({
|
||||
getParameterValues: jest.fn().mockImplementation(param => {
|
||||
if (param === 'start') return ['2020-01-01T18:27:47.000Z'];
|
||||
if (param === 'end') return ['2020-01-01T18:57:47.000Z'];
|
||||
return [];
|
||||
}),
|
||||
}));
|
||||
jest.mock('~/lib/utils/url_utility');
|
||||
|
||||
describe('Dashboard template', () => {
|
||||
let wrapper;
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import createFlash from '~/flash';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Dashboard from '~/monitoring/components/dashboard.vue';
|
||||
import { createStore } from '~/monitoring/stores';
|
||||
import { propsData } from '../init_utils';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
jest.mock('~/flash');
|
||||
|
||||
jest.mock('~/lib/utils/url_utility', () => ({
|
||||
getParameterValues: jest.fn().mockReturnValue('<script>alert("XSS")</script>'),
|
||||
}));
|
||||
|
||||
describe('dashboard invalid url parameters', () => {
|
||||
let store;
|
||||
let wrapper;
|
||||
let mock;
|
||||
|
||||
const createMountedWrapper = (props = {}, options = {}) => {
|
||||
wrapper = mount(Dashboard, {
|
||||
propsData: { ...propsData, ...props },
|
||||
store,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.destroy();
|
||||
}
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows an error message if invalid url parameters are passed', done => {
|
||||
createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
|
||||
|
||||
wrapper.vm
|
||||
.$nextTick()
|
||||
.then(() => {
|
||||
expect(createFlash).toHaveBeenCalled();
|
||||
done();
|
||||
})
|
||||
.catch(done.fail);
|
||||
});
|
||||
});
|
|
@ -1,69 +0,0 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import { GlDropdownItem } from '@gitlab/ui';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import statusCodes from '~/lib/utils/http_status';
|
||||
import Dashboard from '~/monitoring/components/dashboard.vue';
|
||||
import { createStore } from '~/monitoring/stores';
|
||||
import { propsData, setupComponentStore } from '../init_utils';
|
||||
import { metricsDashboardPayload, mockApiEndpoint } from '../mock_data';
|
||||
|
||||
jest.mock('~/lib/utils/url_utility', () => ({
|
||||
getParameterValues: jest.fn().mockImplementation(param => {
|
||||
if (param === 'start') return ['2019-10-01T18:27:47.000Z'];
|
||||
if (param === 'end') return ['2019-10-01T18:57:47.000Z'];
|
||||
return [];
|
||||
}),
|
||||
mergeUrlParams: jest.fn().mockReturnValue('#'),
|
||||
}));
|
||||
|
||||
describe('dashboard time window', () => {
|
||||
let store;
|
||||
let wrapper;
|
||||
let mock;
|
||||
|
||||
const createComponentWrapperMounted = (props = {}, options = {}) => {
|
||||
wrapper = mount(Dashboard, {
|
||||
propsData: { ...propsData, ...props },
|
||||
store,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.destroy();
|
||||
}
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows an active quick range option', done => {
|
||||
mock.onGet(mockApiEndpoint).reply(statusCodes.OK, metricsDashboardPayload);
|
||||
|
||||
createComponentWrapperMounted({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] });
|
||||
|
||||
setupComponentStore(wrapper);
|
||||
|
||||
wrapper.vm
|
||||
.$nextTick()
|
||||
.then(() => {
|
||||
const timeWindowDropdownItems = wrapper
|
||||
.find({ ref: 'dateTimePicker' })
|
||||
.findAll(GlDropdownItem);
|
||||
|
||||
const activeItem = timeWindowDropdownItems.wrappers.filter(itemWrapper =>
|
||||
itemWrapper.find('.active').exists(),
|
||||
);
|
||||
|
||||
expect(activeItem.length).toBe(1);
|
||||
|
||||
done();
|
||||
})
|
||||
.catch(done.fail);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,145 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import createFlash from '~/flash';
|
||||
import { queryToObject, redirectTo, removeParams, mergeUrlParams } from '~/lib/utils/url_utility';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { mockProjectDir } from '../mock_data';
|
||||
|
||||
import Dashboard from '~/monitoring/components/dashboard.vue';
|
||||
import { createStore } from '~/monitoring/stores';
|
||||
import { propsData } from '../init_utils';
|
||||
|
||||
jest.mock('~/flash');
|
||||
jest.mock('~/lib/utils/url_utility');
|
||||
|
||||
describe('dashboard invalid url parameters', () => {
|
||||
let store;
|
||||
let wrapper;
|
||||
let mock;
|
||||
|
||||
const fetchDataMock = jest.fn();
|
||||
|
||||
const createMountedWrapper = (props = { hasMetrics: true }, options = {}) => {
|
||||
wrapper = mount(Dashboard, {
|
||||
propsData: { ...propsData, ...props },
|
||||
store,
|
||||
stubs: ['graph-group', 'panel-type'],
|
||||
methods: {
|
||||
fetchData: fetchDataMock,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
const findDateTimePicker = () => wrapper.find({ ref: 'dateTimePicker' });
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore();
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.destroy();
|
||||
}
|
||||
mock.restore();
|
||||
fetchDataMock.mockReset();
|
||||
queryToObject.mockReset();
|
||||
});
|
||||
|
||||
it('passes default url parameters to the time range picker', () => {
|
||||
queryToObject.mockReturnValue({});
|
||||
|
||||
createMountedWrapper();
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findDateTimePicker().props('value')).toMatchObject({
|
||||
duration: { seconds: 28800 },
|
||||
});
|
||||
|
||||
expect(fetchDataMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchDataMock).toHaveBeenCalledWith({
|
||||
start: expect.any(String),
|
||||
end: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a fixed time range in the URL to the time range picker', () => {
|
||||
const params = {
|
||||
start: '2019-01-01T00:00:00.000Z',
|
||||
end: '2019-01-10T00:00:00.000Z',
|
||||
};
|
||||
|
||||
queryToObject.mockReturnValue(params);
|
||||
|
||||
createMountedWrapper();
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findDateTimePicker().props('value')).toEqual(params);
|
||||
|
||||
expect(fetchDataMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchDataMock).toHaveBeenCalledWith(params);
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a rolling time range in the URL to the time range picker', () => {
|
||||
queryToObject.mockReturnValue({
|
||||
duration_seconds: '120',
|
||||
});
|
||||
|
||||
createMountedWrapper();
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findDateTimePicker().props('value')).toMatchObject({
|
||||
duration: { seconds: 60 * 2 },
|
||||
});
|
||||
|
||||
expect(fetchDataMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchDataMock).toHaveBeenCalledWith({
|
||||
start: expect.any(String),
|
||||
end: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error message and loads a default time range if invalid url parameters are passed', () => {
|
||||
queryToObject.mockReturnValue({
|
||||
start: '<script>alert("XSS")</script>',
|
||||
end: '<script>alert("XSS")</script>',
|
||||
});
|
||||
|
||||
createMountedWrapper();
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(createFlash).toHaveBeenCalled();
|
||||
|
||||
expect(findDateTimePicker().props('value')).toMatchObject({
|
||||
duration: { seconds: 28800 },
|
||||
});
|
||||
|
||||
expect(fetchDataMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchDataMock).toHaveBeenCalledWith({
|
||||
start: expect.any(String),
|
||||
end: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to different time range', () => {
|
||||
const toUrl = `${mockProjectDir}/-/environments/1/metrics`;
|
||||
removeParams.mockReturnValueOnce(toUrl);
|
||||
|
||||
createMountedWrapper();
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
findDateTimePicker().vm.$emit('input', {
|
||||
duration: { seconds: 120 },
|
||||
});
|
||||
|
||||
// redirect to plus + new parameters
|
||||
expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: '120' }, toUrl);
|
||||
expect(redirectTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -90,11 +90,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
|
|||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="s16 ic-duplicate"
|
||||
class="gl-icon s16"
|
||||
>
|
||||
<use
|
||||
xlink:href="#duplicate"
|
||||
href="#copy-to-clipboard"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
@ -128,11 +127,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
|
|||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="s16 ic-duplicate"
|
||||
class="gl-icon s16"
|
||||
>
|
||||
<use
|
||||
xlink:href="#duplicate"
|
||||
href="#copy-to-clipboard"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
@ -158,11 +156,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
|
|||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="s16 ic-duplicate"
|
||||
class="gl-icon s16"
|
||||
>
|
||||
<use
|
||||
xlink:href="#duplicate"
|
||||
href="#copy-to-clipboard"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import SnippetApp from '~/snippets/components/app.vue';
|
||||
import SnippetHeader from '~/snippets/components/snippet_header.vue';
|
||||
import SnippetTitle from '~/snippets/components/snippet_title.vue';
|
||||
import SnippetBlob from '~/snippets/components/snippet_blob_view.vue';
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
@ -35,8 +37,10 @@ describe('Snippet view app', () => {
|
|||
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders SnippetHeader component after the query is finished', () => {
|
||||
it('renders all components after the query is finished', () => {
|
||||
createComponent();
|
||||
expect(wrapper.find(SnippetHeader).exists()).toBe(true);
|
||||
expect(wrapper.find(SnippetTitle).exists()).toBe(true);
|
||||
expect(wrapper.find(SnippetBlob).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue';
|
||||
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
|
||||
import {
|
||||
SNIPPET_VISIBILITY_PRIVATE,
|
||||
SNIPPET_VISIBILITY_INTERNAL,
|
||||
SNIPPET_VISIBILITY_PUBLIC,
|
||||
} from '~/snippets/constants';
|
||||
|
||||
describe('Blob Embeddable', () => {
|
||||
let wrapper;
|
||||
const snippet = {
|
||||
id: 'gid://foo.bar/snippet',
|
||||
webUrl: 'https://foo.bar',
|
||||
visibilityLevel: SNIPPET_VISIBILITY_PUBLIC,
|
||||
};
|
||||
|
||||
function createComponent(props = {}) {
|
||||
wrapper = shallowMount(SnippetBlobView, {
|
||||
propsData: {
|
||||
snippet: {
|
||||
...snippet,
|
||||
...props,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders blob-embeddable component', () => {
|
||||
createComponent();
|
||||
expect(wrapper.find(BlobEmbeddable).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render blob-embeddable for internal snippet', () => {
|
||||
createComponent({
|
||||
visibilityLevel: SNIPPET_VISIBILITY_INTERNAL,
|
||||
});
|
||||
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
|
||||
|
||||
createComponent({
|
||||
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
|
||||
});
|
||||
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
|
||||
|
||||
createComponent({
|
||||
visibilityLevel: 'foo',
|
||||
});
|
||||
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
|
||||
});
|
||||
});
|
|
@ -1,7 +1,6 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import { GlButton, GlIcon } from '@gitlab/ui';
|
||||
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
|
||||
import Icon from '~/vue_shared/components/icon.vue';
|
||||
|
||||
describe('clipboard button', () => {
|
||||
let wrapper;
|
||||
|
@ -29,7 +28,7 @@ describe('clipboard button', () => {
|
|||
it('renders a button for clipboard', () => {
|
||||
expect(wrapper.find(GlButton).exists()).toBe(true);
|
||||
expect(wrapper.attributes('data-clipboard-text')).toBe('copy me');
|
||||
expect(wrapper.find(Icon).props('name')).toBe('duplicate');
|
||||
expect(wrapper.find(GlIcon).props('name')).toBe('copy-to-clipboard');
|
||||
});
|
||||
|
||||
it('should have a tooltip with default values', () => {
|
||||
|
|
|
@ -54,97 +54,6 @@ describe('date time picker lib', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getTimeWindow', () => {
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{
|
||||
start: '2019-10-01T18:27:47.000Z',
|
||||
end: '2019-10-01T21:27:47.000Z',
|
||||
},
|
||||
dateTimePickerLib.defaultTimeWindows,
|
||||
],
|
||||
expected: 'threeHours',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
{
|
||||
start: '2019-10-01T28:27:47.000Z',
|
||||
end: '2019-10-01T21:27:47.000Z',
|
||||
},
|
||||
dateTimePickerLib.defaultTimeWindows,
|
||||
],
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
args: [
|
||||
{
|
||||
start: '',
|
||||
end: '',
|
||||
},
|
||||
dateTimePickerLib.defaultTimeWindows,
|
||||
],
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
args: [
|
||||
{
|
||||
start: null,
|
||||
end: null,
|
||||
},
|
||||
dateTimePickerLib.defaultTimeWindows,
|
||||
],
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
args: [{}, dateTimePickerLib.defaultTimeWindows],
|
||||
expected: null,
|
||||
},
|
||||
].forEach(({ args, expected }) => {
|
||||
it(`returns "${expected}" with args=${JSON.stringify(args)}`, () => {
|
||||
expect(dateTimePickerLib.getTimeWindowKey(...args)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeRange', () => {
|
||||
function secondsBetween({ start, end }) {
|
||||
return (new Date(end) - new Date(start)) / 1000;
|
||||
}
|
||||
|
||||
function minutesBetween(timeRange) {
|
||||
return secondsBetween(timeRange) / 60;
|
||||
}
|
||||
|
||||
function hoursBetween(timeRange) {
|
||||
return minutesBetween(timeRange) / 60;
|
||||
}
|
||||
|
||||
it('defaults to an 8 hour (28800s) difference', () => {
|
||||
const params = dateTimePickerLib.getTimeRange();
|
||||
|
||||
expect(hoursBetween(params)).toEqual(8);
|
||||
});
|
||||
|
||||
it('accepts time window as an argument', () => {
|
||||
const params = dateTimePickerLib.getTimeRange('thirtyMinutes');
|
||||
|
||||
expect(minutesBetween(params)).toEqual(30);
|
||||
});
|
||||
|
||||
it('returns a value for every defined time window', () => {
|
||||
const nonDefaultWindows = Object.entries(dateTimePickerLib.defaultTimeWindows).filter(
|
||||
([, timeWindow]) => !timeWindow.default,
|
||||
);
|
||||
nonDefaultWindows.forEach(timeWindow => {
|
||||
const params = dateTimePickerLib.getTimeRange(timeWindow[0]);
|
||||
|
||||
// Ensure we're not returning the default
|
||||
expect(hoursBetween(params)).not.toEqual(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringToISODate', () => {
|
||||
['', 'null', undefined, 'abc'].forEach(input => {
|
||||
it(`throws error for invalid input like ${input}`, done => {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
|
||||
import { defaultTimeWindows } from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
|
||||
import {
|
||||
defaultTimeRanges,
|
||||
defaultTimeRange,
|
||||
} from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
|
||||
|
||||
const timeWindowsCount = Object.entries(defaultTimeWindows).length;
|
||||
const start = '2019-10-10T07:00:00.000Z';
|
||||
const end = '2019-10-13T07:00:00.000Z';
|
||||
const selectedTimeWindowText = `3 days`;
|
||||
const optionsCount = defaultTimeRanges.length;
|
||||
|
||||
describe('DateTimePicker', () => {
|
||||
let dateTimePicker;
|
||||
|
@ -15,19 +15,10 @@ describe('DateTimePicker', () => {
|
|||
const applyButtonElement = () => dateTimePicker.find('button.btn-success').element;
|
||||
const findQuickRangeItems = () => dateTimePicker.findAll('.dropdown-item');
|
||||
const cancelButtonElement = () => dateTimePicker.find('button.btn-secondary').element;
|
||||
const fillInputAndBlur = (input, val) => {
|
||||
dateTimePicker.find(input).setValue(val);
|
||||
return dateTimePicker.vm.$nextTick().then(() => {
|
||||
dateTimePicker.find(input).trigger('blur');
|
||||
return dateTimePicker.vm.$nextTick();
|
||||
});
|
||||
};
|
||||
|
||||
const createComponent = props => {
|
||||
dateTimePicker = mount(DateTimePicker, {
|
||||
propsData: {
|
||||
start,
|
||||
end,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
@ -40,7 +31,7 @@ describe('DateTimePicker', () => {
|
|||
it('renders dropdown toggle button with selected text', done => {
|
||||
createComponent();
|
||||
dateTimePicker.vm.$nextTick(() => {
|
||||
expect(dropdownToggle().text()).toBe(selectedTimeWindowText);
|
||||
expect(dropdownToggle().text()).toBe(defaultTimeRange.label);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
@ -54,8 +45,10 @@ describe('DateTimePicker', () => {
|
|||
|
||||
it('renders inputs with h/m/s truncated if its all 0s', done => {
|
||||
createComponent({
|
||||
start: '2019-10-10T00:00:00.000Z',
|
||||
end: '2019-10-14T00:10:00.000Z',
|
||||
value: {
|
||||
start: '2019-10-10T00:00:00.000Z',
|
||||
end: '2019-10-14T00:10:00.000Z',
|
||||
},
|
||||
});
|
||||
dateTimePicker.vm.$nextTick(() => {
|
||||
expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10');
|
||||
|
@ -64,22 +57,21 @@ describe('DateTimePicker', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it(`renders dropdown with ${timeWindowsCount} (default) items in quick range`, done => {
|
||||
it(`renders dropdown with ${optionsCount} (default) items in quick range`, done => {
|
||||
createComponent();
|
||||
dropdownToggle().trigger('click');
|
||||
dateTimePicker.vm.$nextTick(() => {
|
||||
expect(findQuickRangeItems().length).toBe(timeWindowsCount);
|
||||
expect(findQuickRangeItems().length).toBe(optionsCount);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it(`renders dropdown with correct quick range item selected`, done => {
|
||||
it('renders dropdown with a default quick range item selected', done => {
|
||||
createComponent();
|
||||
dropdownToggle().trigger('click');
|
||||
dateTimePicker.vm.$nextTick(() => {
|
||||
expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(selectedTimeWindowText);
|
||||
|
||||
expect(dateTimePicker.find('.dropdown-item.active svg').isVisible()).toBe(true);
|
||||
expect(dateTimePicker.find('.dropdown-item.active').exists()).toBe(true);
|
||||
expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(defaultTimeRange.label);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
@ -92,99 +84,142 @@ describe('DateTimePicker', () => {
|
|||
expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
|
||||
});
|
||||
|
||||
it('displays inline error message if custom time range inputs are invalid', done => {
|
||||
createComponent();
|
||||
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);
|
||||
});
|
||||
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();
|
||||
});
|
||||
};
|
||||
|
||||
it('keeps apply button disabled with invalid custom time range inputs', done => {
|
||||
createComponent();
|
||||
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);
|
||||
});
|
||||
beforeEach(done => {
|
||||
createComponent();
|
||||
dateTimePicker.vm.$nextTick(done);
|
||||
});
|
||||
|
||||
it('enables apply button with valid custom time range inputs', done => {
|
||||
createComponent();
|
||||
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('displays inline error message if custom time range inputs are invalid', done => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('emits dates in an object when apply is clicked', done => {
|
||||
createComponent();
|
||||
fillInputAndBlur('#custom-time-from', '2019-10-01')
|
||||
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
|
||||
.then(() => {
|
||||
applyButtonElement().click();
|
||||
it('keeps apply button disabled with invalid custom time range inputs', done => {
|
||||
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);
|
||||
});
|
||||
|
||||
expect(dateTimePicker.emitted().apply).toHaveLength(1);
|
||||
expect(dateTimePicker.emitted().apply[0]).toEqual([
|
||||
{
|
||||
end: '2019-10-19T00:00:00Z',
|
||||
start: '2019-10-01T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
done();
|
||||
})
|
||||
.catch(done.fail);
|
||||
});
|
||||
it('enables apply button with valid custom time range inputs', done => {
|
||||
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('hides the popover with cancel button', done => {
|
||||
createComponent();
|
||||
dropdownToggle().trigger('click');
|
||||
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();
|
||||
|
||||
dateTimePicker.vm.$nextTick(() => {
|
||||
cancelButtonElement().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);
|
||||
});
|
||||
|
||||
it('unchecks quick range when text is input is clicked', done => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('emits dates in an object when a is clicked', () => {
|
||||
findQuickRangeItems()
|
||||
.at(3) // any item
|
||||
.trigger('click');
|
||||
|
||||
expect(dateTimePicker.emitted().input).toHaveLength(1);
|
||||
expect(dateTimePicker.emitted().input[0][0]).toMatchObject({
|
||||
duration: {
|
||||
seconds: expect.any(Number),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the popover with cancel button', done => {
|
||||
dropdownToggle().trigger('click');
|
||||
|
||||
dateTimePicker.vm.$nextTick(() => {
|
||||
expect(dropdownMenu().classes('show')).toBe(false);
|
||||
done();
|
||||
cancelButtonElement().click();
|
||||
|
||||
dateTimePicker.vm.$nextTick(() => {
|
||||
expect(dropdownMenu().classes('show')).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using non-default time windows', () => {
|
||||
const otherTimeWindows = {
|
||||
oneMinute: {
|
||||
const MOCK_NOW = Date.UTC(2020, 0, 23, 20);
|
||||
|
||||
const otherTimeRanges = [
|
||||
{
|
||||
label: '1 minute',
|
||||
seconds: 60,
|
||||
duration: { seconds: 60 },
|
||||
},
|
||||
twoMinutes: {
|
||||
{
|
||||
label: '2 minutes',
|
||||
seconds: 60 * 2,
|
||||
duration: { seconds: 60 * 2 },
|
||||
default: true,
|
||||
},
|
||||
fiveMinutes: {
|
||||
{
|
||||
label: '5 minutes',
|
||||
seconds: 60 * 5,
|
||||
duration: { seconds: 60 * 5 },
|
||||
},
|
||||
};
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => MOCK_NOW);
|
||||
});
|
||||
|
||||
it('renders dropdown with a label in the quick range', done => {
|
||||
createComponent({
|
||||
// 2 minutes range
|
||||
start: '2020-01-21T15:00:00.000Z',
|
||||
end: '2020-01-21T15:02:00.000Z',
|
||||
timeWindows: otherTimeWindows,
|
||||
value: {
|
||||
duration: { seconds: 60 * 5 },
|
||||
},
|
||||
options: otherTimeRanges,
|
||||
});
|
||||
dropdownToggle().trigger('click');
|
||||
dateTimePicker.vm.$nextTick(() => {
|
||||
expect(dropdownToggle().text()).toBe('2 minutes');
|
||||
expect(dropdownToggle().text()).toBe('5 minutes');
|
||||
|
||||
done();
|
||||
});
|
||||
|
@ -192,16 +227,16 @@ describe('DateTimePicker', () => {
|
|||
|
||||
it('renders dropdown with quick range items', done => {
|
||||
createComponent({
|
||||
// 2 minutes range
|
||||
start: '2020-01-21T15:00:00.000Z',
|
||||
end: '2020-01-21T15:02:00.000Z',
|
||||
timeWindows: otherTimeWindows,
|
||||
value: {
|
||||
duration: { seconds: 60 * 2 },
|
||||
},
|
||||
options: otherTimeRanges,
|
||||
});
|
||||
dropdownToggle().trigger('click');
|
||||
dateTimePicker.vm.$nextTick(() => {
|
||||
const items = findQuickRangeItems();
|
||||
|
||||
expect(items.length).toBe(Object.keys(otherTimeWindows).length);
|
||||
expect(items.length).toBe(Object.keys(otherTimeRanges).length);
|
||||
expect(items.at(0).text()).toBe('1 minute');
|
||||
expect(items.at(0).is('.active')).toBe(false);
|
||||
|
||||
|
@ -217,14 +252,13 @@ describe('DateTimePicker', () => {
|
|||
|
||||
it('renders dropdown with a label not in the quick range', done => {
|
||||
createComponent({
|
||||
// 10 minutes range
|
||||
start: '2020-01-21T15:00:00.000Z',
|
||||
end: '2020-01-21T15:10:00.000Z',
|
||||
timeWindows: otherTimeWindows,
|
||||
value: {
|
||||
duration: { seconds: 60 * 4 },
|
||||
},
|
||||
});
|
||||
dropdownToggle().trigger('click');
|
||||
dateTimePicker.vm.$nextTick(() => {
|
||||
expect(dropdownToggle().text()).toBe('2020-01-21 15:00:00 to 2020-01-21 15:10:00');
|
||||
expect(dropdownToggle().text()).toBe('2020-01-23 19:56:00 to 2020-01-23 20:00:00');
|
||||
|
||||
done();
|
||||
});
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::BackgroundMigration::UpdateExistingSubgroupToMatchVisibilityLevelOfParent, :migration, schema: 2020_01_10_121314 do
|
||||
include MigrationHelpers::NamespacesHelpers
|
||||
|
||||
context 'private visibility level' do
|
||||
it 'updates the project visibility' do
|
||||
parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE)
|
||||
child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id)
|
||||
|
||||
expect { subject.perform([parent.id], Gitlab::VisibilityLevel::PRIVATE) }.to change { child.reload.visibility_level }.to(Gitlab::VisibilityLevel::PRIVATE)
|
||||
end
|
||||
|
||||
it 'updates sub-sub groups' do
|
||||
parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE)
|
||||
middle_group = create_namespace('middle', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id)
|
||||
child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
|
||||
|
||||
subject.perform([parent.id, middle_group.id], Gitlab::VisibilityLevel::PRIVATE)
|
||||
|
||||
expect(child.reload.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
|
||||
end
|
||||
|
||||
it 'updates all sub groups' do
|
||||
parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE)
|
||||
middle_group = create_namespace('middle', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id)
|
||||
child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
|
||||
|
||||
subject.perform([parent.id], Gitlab::VisibilityLevel::PRIVATE)
|
||||
|
||||
expect(child.reload.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
|
||||
expect(middle_group.reload.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
|
||||
end
|
||||
end
|
||||
|
||||
context 'internal visibility level' do
|
||||
it 'updates the project visibility' do
|
||||
parent = create_namespace('parent', Gitlab::VisibilityLevel::INTERNAL)
|
||||
child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id)
|
||||
|
||||
expect { subject.perform([parent.id], Gitlab::VisibilityLevel::INTERNAL) }.to change { child.reload.visibility_level }.to(Gitlab::VisibilityLevel::INTERNAL)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -128,4 +128,23 @@ describe Gitlab::Database::WithLockRetries do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'casting durations correctly' do
|
||||
let(:timing_configuration) { [[0.015.seconds, 0.025.seconds], [0.015.seconds, 0.025.seconds]] } # 15ms, 25ms
|
||||
|
||||
it 'executes `SET LOCAL lock_timeout` using the configured timeout value in milliseconds' do
|
||||
expect(ActiveRecord::Base.connection).to receive(:execute).with("SAVEPOINT active_record_1").and_call_original
|
||||
expect(ActiveRecord::Base.connection).to receive(:execute).with("SET LOCAL lock_timeout TO '15ms'").and_call_original
|
||||
expect(ActiveRecord::Base.connection).to receive(:execute).with("RELEASE SAVEPOINT active_record_1").and_call_original
|
||||
|
||||
subject.run { }
|
||||
end
|
||||
|
||||
it 'calls `sleep` after the first iteration fails, using the configured sleep time' do
|
||||
expect(subject).to receive(:run_block_with_transaction).and_raise(ActiveRecord::LockWaitTimeout).twice
|
||||
expect(subject).to receive(:sleep).with(0.025)
|
||||
|
||||
subject.run { }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
require Rails.root.join('db', 'post_migrate', '20200110121314_schedule_update_existing_subgroup_to_match_visibility_level_of_parent.rb')
|
||||
|
||||
describe ScheduleUpdateExistingSubgroupToMatchVisibilityLevelOfParent, :migration, :sidekiq do
|
||||
include MigrationHelpers::NamespacesHelpers
|
||||
let(:migration_class) { described_class::MIGRATION }
|
||||
let(:migration_name) { migration_class.to_s.demodulize }
|
||||
|
||||
context 'private visibility level' do
|
||||
it 'correctly schedules background migrations' do
|
||||
parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE)
|
||||
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id)
|
||||
|
||||
Sidekiq::Testing.fake! do
|
||||
Timecop.freeze do
|
||||
migrate!
|
||||
|
||||
expect(BackgroundMigrationWorker.jobs.size).to eq(1)
|
||||
expect(migration_name).to be_scheduled_migration_with_multiple_args([parent.id], Gitlab::VisibilityLevel::PRIVATE)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'correctly schedules background migrations for groups and subgroups' do
|
||||
parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE)
|
||||
middle_group = create_namespace('middle_group', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id)
|
||||
create_namespace('middle_empty_group', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id)
|
||||
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
|
||||
|
||||
Sidekiq::Testing.fake! do
|
||||
Timecop.freeze do
|
||||
migrate!
|
||||
|
||||
expect(BackgroundMigrationWorker.jobs.size).to eq(1)
|
||||
expect(migration_name).to be_scheduled_migration_with_multiple_args([middle_group.id, parent.id], Gitlab::VisibilityLevel::PRIVATE)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'internal visibility level' do
|
||||
it 'correctly schedules background migrations' do
|
||||
parent = create_namespace('parent', Gitlab::VisibilityLevel::INTERNAL)
|
||||
middle_group = create_namespace('child', Gitlab::VisibilityLevel::INTERNAL, parent_id: parent.id)
|
||||
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
|
||||
|
||||
Sidekiq::Testing.fake! do
|
||||
Timecop.freeze do
|
||||
migrate!
|
||||
|
||||
expect(BackgroundMigrationWorker.jobs.size).to eq(1)
|
||||
expect(migration_name).to be_scheduled_migration_with_multiple_args([parent.id, middle_group.id], Gitlab::VisibilityLevel::INTERNAL)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'mixed visibility levels' do
|
||||
it 'correctly schedules background migrations' do
|
||||
parent1 = create_namespace('parent1', Gitlab::VisibilityLevel::INTERNAL)
|
||||
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent1.id)
|
||||
parent2 = create_namespace('parent2', Gitlab::VisibilityLevel::PRIVATE)
|
||||
middle_group = create_namespace('middle_group', Gitlab::VisibilityLevel::INTERNAL, parent_id: parent2.id)
|
||||
create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id)
|
||||
|
||||
Sidekiq::Testing.fake! do
|
||||
Timecop.freeze do
|
||||
migrate!
|
||||
|
||||
expect(BackgroundMigrationWorker.jobs.size).to eq(2)
|
||||
expect(migration_name).to be_scheduled_migration_with_multiple_args([parent1.id, middle_group.id], Gitlab::VisibilityLevel::INTERNAL)
|
||||
expect(migration_name).to be_scheduled_migration_with_multiple_args([parent2.id], Gitlab::VisibilityLevel::PRIVATE)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -26,3 +26,26 @@ RSpec::Matchers.define :be_scheduled_migration do |*expected|
|
|||
"Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
|
||||
end
|
||||
end
|
||||
|
||||
RSpec::Matchers.define :be_scheduled_migration_with_multiple_args do |*expected|
|
||||
match do |migration|
|
||||
BackgroundMigrationWorker.jobs.any? do |job|
|
||||
args = job['args'].size == 1 ? [BackgroundMigrationWorker.jobs[0]['args'][0], []] : job['args']
|
||||
args[0] == migration && compare_args(args, expected)
|
||||
end
|
||||
end
|
||||
|
||||
failure_message do |migration|
|
||||
"Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
|
||||
end
|
||||
|
||||
def compare_args(args, expected)
|
||||
args[1].map.with_index do |arg, i|
|
||||
arg.is_a?(Array) ? same_arrays?(arg, expected[i]) : arg == expected[i]
|
||||
end.all?
|
||||
end
|
||||
|
||||
def same_arrays?(arg, expected)
|
||||
arg.sort == expected.sort
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module MigrationHelpers
|
||||
module NamespacesHelpers
|
||||
def create_namespace(name, visibility, options = {})
|
||||
table(:namespaces).create({
|
||||
name: name,
|
||||
path: name,
|
||||
type: 'Group',
|
||||
visibility_level: visibility
|
||||
}.merge(options))
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue