Commit c27acb1d authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 18084543
...@@ -350,7 +350,7 @@ end ...@@ -350,7 +350,7 @@ end
group :development, :test do group :development, :test do
gem 'bullet', '~> 6.0.2', require: !!ENV['ENABLE_BULLET'] gem 'bullet', '~> 6.0.2', require: !!ENV['ENABLE_BULLET']
gem 'pry-byebug', '~> 3.5.1', platform: :mri 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 gem 'awesome_print', require: false
......
...@@ -778,7 +778,7 @@ GEM ...@@ -778,7 +778,7 @@ GEM
pry-byebug (3.5.1) pry-byebug (3.5.1)
byebug (~> 9.1) byebug (~> 9.1)
pry (~> 0.10) pry (~> 0.10)
pry-rails (0.3.6) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.3) public_suffix (4.0.3)
pyu-ruby-sasl (0.0.3.3) pyu-ruby-sasl (0.0.3.3)
...@@ -1323,7 +1323,7 @@ DEPENDENCIES ...@@ -1323,7 +1323,7 @@ DEPENDENCIES
premailer-rails (~> 1.10.3) premailer-rails (~> 1.10.3)
prometheus-client-mmap (~> 0.10.0) prometheus-client-mmap (~> 0.10.0)
pry-byebug (~> 3.5.1) pry-byebug (~> 3.5.1)
pry-rails (~> 0.3.4) pry-rails (~> 0.3.9)
rack (~> 2.0.7) rack (~> 2.0.7)
rack-attack (~> 6.2.0) rack-attack (~> 6.2.0)
rack-cors (~> 1.0.0) rack-cors (~> 1.0.0)
......
<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'; ...@@ -19,10 +19,10 @@ import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import createFlash from '~/flash'; import createFlash from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url'; import invalidUrl from '~/lib/utils/invalid_url';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import Icon from '~/vue_shared/components/icon.vue'; 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 DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import GraphGroup from './graph_group.vue'; import GraphGroup from './graph_group.vue';
...@@ -31,11 +31,8 @@ import GroupEmptyState from './group_empty_state.vue'; ...@@ -31,11 +31,8 @@ import GroupEmptyState from './group_empty_state.vue';
import DashboardsDropdown from './dashboards_dropdown.vue'; import DashboardsDropdown from './dashboards_dropdown.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event'; import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getAddMetricTrackingOptions } from '../utils'; import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils';
import { defaultTimeRange, timeRanges, metricStates } from '../constants';
import { datePickerTimeWindows, metricStates } from '../constants';
const defaultTimeRange = getTimeRange();
export default { export default {
components: { components: {
...@@ -197,10 +194,9 @@ export default { ...@@ -197,10 +194,9 @@ export default {
return { return {
state: 'gettingStarted', state: 'gettingStarted',
formIsValid: null, formIsValid: null,
startDate: getParameterValues('start')[0] || defaultTimeRange.start, selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
endDate: getParameterValues('end')[0] || defaultTimeRange.end,
hasValidDates: true, hasValidDates: true,
datePickerTimeWindows, timeRanges,
isRearrangingPanels: false, isRearrangingPanels: false,
}; };
}, },
...@@ -260,9 +256,11 @@ export default { ...@@ -260,9 +256,11 @@ export default {
if (!this.hasMetrics) { if (!this.hasMetrics) {
this.setGettingStartedEmptyState(); this.setGettingStartedEmptyState();
} else { } else {
const { start, end } = convertToFixedRange(this.selectedTimeRange);
this.fetchData({ this.fetchData({
start: this.startDate, start,
end: this.endDate, end,
}); });
} }
}, },
...@@ -287,8 +285,8 @@ export default { ...@@ -287,8 +285,8 @@ export default {
}); });
}, },
onDateTimePickerApply(params) { onDateTimePickerInput(timeRange) {
redirectTo(mergeUrlParams(params, window.location.href)); redirectTo(timeRangeToUrl(timeRange));
}, },
onDateTimePickerInvalid() { onDateTimePickerInvalid() {
createFlash( createFlash(
...@@ -296,8 +294,8 @@ export default { ...@@ -296,8 +294,8 @@ export default {
'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.', 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
), ),
); );
this.startDate = defaultTimeRange.start; // As a fallback, switch to default time range instead
this.endDate = defaultTimeRange.end; this.selectedTimeRange = defaultTimeRange;
}, },
generateLink(group, title, yLabel) { generateLink(group, title, yLabel) {
...@@ -447,10 +445,9 @@ export default { ...@@ -447,10 +445,9 @@ export default {
> >
<date-time-picker <date-time-picker
ref="dateTimePicker" ref="dateTimePicker"
:start="startDate" :value="selectedTimeRange"
:end="endDate" :options="timeRanges"
:time-windows="datePickerTimeWindows" @input="onDateTimePickerInput"
@apply="onDateTimePickerApply"
@invalid="onDateTimePickerInvalid" @invalid="onDateTimePickerInvalid"
/> />
</gl-form-group> </gl-form-group>
......
<script> <script>
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { sidebarAnimationDuration } from '../constants'; import { timeRangeFromUrl, removeTimeRangeParams } from '../utils';
import { getTimeRange } from '~/vue_shared/components/date_time_picker/date_time_picker_lib'; import { sidebarAnimationDuration, defaultTimeRange } from '../constants';
let sidebarMutationObserver; let sidebarMutationObserver;
...@@ -18,10 +18,8 @@ export default { ...@@ -18,10 +18,8 @@ export default {
}, },
}, },
data() { data() {
const defaultRange = getTimeRange(); const timeRange = timeRangeFromUrl(this.dashboardUrl) || defaultTimeRange;
const start = getParameterValues('start', this.dashboardUrl)[0] || defaultRange.start; const { start, end } = convertToFixedRange(timeRange);
const end = getParameterValues('end', this.dashboardUrl)[0] || defaultRange.end;
const params = { const params = {
start, start,
end, end,
...@@ -81,7 +79,7 @@ export default { ...@@ -81,7 +79,7 @@ export default {
}, },
setInitialState() { setInitialState() {
this.setEndpoints({ this.setEndpoints({
dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl), dashboardEndpoint: removeTimeRangeParams(this.dashboardUrl),
}); });
this.setShowErrorBanner(false); this.setShowErrorBanner(false);
}, },
......
...@@ -83,34 +83,36 @@ export const dateFormats = { ...@@ -83,34 +83,36 @@ export const dateFormats = {
default: 'dd mmm yyyy, h:MMTT', default: 'dd mmm yyyy, h:MMTT',
}; };
export const datePickerTimeWindows = { export const timeRanges = [
thirtyMinutes: { {
label: __('30 minutes'), label: __('30 minutes'),
seconds: 60 * 30, duration: { seconds: 60 * 30 },
}, },
threeHours: { {
label: __('3 hours'), label: __('3 hours'),
seconds: 60 * 60 * 3, duration: { seconds: 60 * 60 * 3 },
}, },
eightHours: { {
label: __('8 hours'), label: __('8 hours'),
seconds: 60 * 60 * 8, duration: { seconds: 60 * 60 * 8 },
default: true, default: true,
}, },
oneDay: { {
label: __('1 day'), label: __('1 day'),
seconds: 60 * 60 * 24 * 1, duration: { seconds: 60 * 60 * 24 * 1 },
}, },
threeDays: { {
label: __('3 days'), label: __('3 days'),
seconds: 60 * 60 * 24 * 3, duration: { seconds: 60 * 60 * 24 * 3 },
}, },
oneWeek: { {
label: __('1 week'), label: __('1 week'),
seconds: 60 * 60 * 24 * 7 * 1, duration: { seconds: 60 * 60 * 24 * 7 * 1 },
}, },
twoWeeks: { {
label: __('2 weeks'), 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 @@ ...@@ -2,6 +2,7 @@
import GetSnippetQuery from '../queries/snippet.query.graphql'; import GetSnippetQuery from '../queries/snippet.query.graphql';
import SnippetHeader from './snippet_header.vue'; import SnippetHeader from './snippet_header.vue';
import SnippetTitle from './snippet_title.vue'; import SnippetTitle from './snippet_title.vue';
import SnippetBlob from './snippet_blob_view.vue';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
export default { export default {
...@@ -9,6 +10,7 @@ export default { ...@@ -9,6 +10,7 @@ export default {
SnippetHeader, SnippetHeader,
SnippetTitle, SnippetTitle,
GlLoadingIcon, GlLoadingIcon,
SnippetBlob,
}, },
apollo: { apollo: {
snippet: { snippet: {
...@@ -50,6 +52,7 @@ export default { ...@@ -50,6 +52,7 @@ export default {
<template v-else> <template v-else>
<snippet-header :snippet="snippet" /> <snippet-header :snippet="snippet" />
<snippet-title :snippet="snippet" /> <snippet-title :snippet="snippet" />
<snippet-blob :snippet="snippet" />
</template> </template>
</div> </div>
</template> </template>
<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>
export const SNIPPET_VISIBILITY_PRIVATE = 'private';
export const SNIPPET_VISIBILITY_INTERNAL = 'internal';
export const SNIPPET_VISIBILITY_PUBLIC = 'public';
...@@ -12,8 +12,7 @@ ...@@ -12,8 +12,7 @@
* css-class="btn-transparent" * css-class="btn-transparent"
* /> * />
*/ */
import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import Icon from '../components/icon.vue';
export default { export default {
name: 'ClipboardButton', name: 'ClipboardButton',
...@@ -22,7 +21,7 @@ export default { ...@@ -22,7 +21,7 @@ export default {
}, },
components: { components: {
GlButton, GlButton,
Icon, GlIcon,
}, },
props: { props: {
text: { text: {
...@@ -72,6 +71,6 @@ export default { ...@@ -72,6 +71,6 @@ export default {
:title="title" :title="title"
:data-clipboard-text="clipboardText" :data-clipboard-text="clipboardText"
> >
<icon name="duplicate" /> <gl-icon name="copy-to-clipboard" />
</gl-button> </gl-button>
</template> </template>
<script> <script>
import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui'; import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import DateTimePickerInput from './date_time_picker_input.vue'; import DateTimePickerInput from './date_time_picker_input.vue';
import { import {
defaultTimeWindows, defaultTimeRanges,
defaultTimeRange,
isValidDate, isValidDate,
getTimeRange,
getTimeWindowKey,
stringToISODate, stringToISODate,
ISODateToString, ISODateToString,
truncateZerosInDateTime, truncateZerosInDateTime,
...@@ -15,7 +17,7 @@ import { ...@@ -15,7 +17,7 @@ import {
} from './date_time_picker_lib'; } from './date_time_picker_lib';
const events = { const events = {
apply: 'apply', input: 'input',
invalid: 'invalid', invalid: 'invalid',
}; };
...@@ -29,24 +31,22 @@ export default { ...@@ -29,24 +31,22 @@ export default {
GlDropdownItem, GlDropdownItem,
}, },
props: { props: {
start: { value: {
type: String,
required: true,
},
end: {
type: String,
required: true,
},
timeWindows: {
type: Object, type: Object,
required: false, required: false,
default: () => defaultTimeWindows, default: () => defaultTimeRange,
},
options: {
type: Array,
required: false,
default: () => defaultTimeRanges,
}, },
}, },
data() { data() {
return { return {
startDate: this.start, timeRange: this.value,
endDate: this.end, startDate: '',
endDate: '',
}; };
}, },
computed: { computed: {
...@@ -67,6 +67,7 @@ export default { ...@@ -67,6 +67,7 @@ export default {
set(val) { set(val) {
// Attempt to set a formatted date if possible // Attempt to set a formatted date if possible
this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
this.timeRange = null;
}, },
}, },
endInput: { endInput: {
...@@ -76,23 +77,48 @@ export default { ...@@ -76,23 +77,48 @@ export default {
set(val) { set(val) {
// Attempt to set a formatted date if possible // Attempt to set a formatted date if possible
this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
this.timeRange = null;
}, },
}, },
timeWindowText() { timeWindowText() {
const timeWindow = getTimeWindowKey({ start: this.start, end: this.end }, this.timeWindows); try {
if (timeWindow) { const timeRange = findTimeRange(this.value, this.options);
return this.timeWindows[timeWindow].label; if (timeRange) {
} else if (isValidDate(this.start) && isValidDate(this.end)) { return timeRange.label;
return sprintf(__('%{start} to %{end}'), { }
start: this.formatDate(this.start),
end: this.formatDate(this.end), 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 ''; return '';
}, },
}, },
watch: {
value(newValue) {
const { start, end } = convertToFixedRange(newValue);
this.timeRange = this.value;
this.startDate = start;
this.endDate = end;
},
},
mounted() { 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 // Validate on mounted, and trigger an update if needed
if (!this.isValid) { if (!this.isValid) {
this.$emit(events.invalid); this.$emit(events.invalid);
...@@ -102,21 +128,22 @@ export default { ...@@ -102,21 +128,22 @@ export default {
formatDate(date) { formatDate(date) {
return truncateZerosInDateTime(ISODateToString(date)); return truncateZerosInDateTime(ISODateToString(date));
}, },
setTimeWindow(key) {
const { start, end } = getTimeRange(key, this.timeWindows);
this.startDate = start;
this.endDate = end;
this.apply();
},
closeDropdown() { closeDropdown() {
this.$refs.dropdown.hide(); this.$refs.dropdown.hide();
}, },
apply() { isOptionActive(option) {
this.$emit(events.apply, { return isEqualTimeRanges(option, this.timeRange);
},
setQuickRange(option) {
this.timeRange = option;
this.$emit(events.input, this.timeRange);
},
setFixedRange() {
this.timeRange = convertToFixedRange({
start: this.startDate, start: this.startDate,
end: this.endDate, end: this.endDate,
}); });
this.$emit(events.input, this.timeRange);
}, },
}, },
}; };
...@@ -146,7 +173,7 @@ export default { ...@@ -146,7 +173,7 @@ export default {
</div> </div>
<gl-form-group> <gl-form-group>
<gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button> <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
<gl-button variant="success" :disabled="!isValid" @click="apply()"> <gl-button variant="success" :disabled="!isValid" @click="setFixedRange()">
{{ __('Apply') }} {{ __('Apply') }}
</gl-button> </gl-button>
</gl-form-group> </gl-form-group>
...@@ -155,19 +182,20 @@ export default { ...@@ -155,19 +182,20 @@ export default {
<template #label> <template #label>
<span class="gl-pl-5">{{ __('Quick range') }}</span> <span class="gl-pl-5">{{ __('Quick range') }}</span>
</template> </template>
<gl-dropdown-item <gl-dropdown-item
v-for="(timeWindow, key) in timeWindows" v-for="(option, index) in options"
:key="key" :key="index"
:active="timeWindow.label === timeWindowText" :active="isOptionActive(option)"
active-class="active" active-class="active"
@click="setTimeWindow(key)" @click="setQuickRange(option)"
> >
<icon <icon
name="mobile-issue-close" name="mobile-issue-close"
class="align-bottom" class="align-bottom"
:class="{ invisible: timeWindow.label !== timeWindowText }" :class="{ invisible: !isOptionActive(option) }"
/> />
{{ timeWindow.label }} {{ option.label }}
</gl-dropdown-item> </gl-dropdown-item>
</gl-form-group> </gl-form-group>
</div> </div>
......
import dateformat from 'dateformat'; import dateformat from 'dateformat';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
/** /**
* Valid strings for this regex are * Valid strings for this regex are
...@@ -9,37 +8,30 @@ import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; ...@@ -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]))?$/; 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". * Default time ranges for the date picker.
* * @see app/assets/javascripts/lib/utils/datetime_range.js
* 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.
*/ */
export const defaultTimeWindows = { export const defaultTimeRanges = [
thirtyMinutes: { {
duration: { seconds: 60 * 30 },
label: __('30 minutes'), label: __('30 minutes'),
seconds: 60 * 30,
}, },
threeHours: { {
duration: { seconds: 60 * 60 * 3 },
label: __('3 hours'), label: __('3 hours'),
seconds: 60 * 60 * 3,
}, },
eightHours: { {
duration: { seconds: 60 * 60 * 8 },
label: __('8 hours'), label: __('8 hours'),
seconds: 60 * 60 * 8,
default: true, default: true,
}, },
oneDay: { {
duration: { seconds: 60 * 60 * 24 * 1 },
label: __('1 day'), 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 = { export const dateFormats = {
ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'", ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
...@@ -67,46 +59,6 @@ export const isValidDate = dateString => { ...@@ -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. * Convert the input in Time picker component to ISO date.
* *
......
---
title: Allow for relative time ranges in metrics dashboard URLs
merge_request: 23765
author:
type: added
---
title: Remove unused Code Hotspots database tables
merge_request: 23590
author:
type: other
---
title: Fix visibility levels of subgroups to be not higher than their parents' level
merge_request: 22889
author:
type: other
---
title: Updated icon for copy-to-clipboard button
merge_request: 24146
author:
type: other
# 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
# 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
# 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
# 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
# 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
# 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
...@@ -94,30 +94,6 @@ ActiveRecord::Schema.define(version: 2020_01_27_090233) do ...@@ -94,30 +94,6 @@ ActiveRecord::Schema.define(version: 2020_01_27_090233) do
t.index ["project_id"], name: "analytics_repository_languages_on_project_id" t.index ["project_id"], name: "analytics_repository_languages_on_project_id"
end 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| create_table "appearances", id: :serial, force: :cascade do |t|
t.string "title", null: false t.string "title", null: false
t.text "description", null: false t.text "description", null: false
...@@ -4476,11 +4452,6 @@ ActiveRecord::Schema.define(version: 2020_01_27_090233) do ...@@ -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_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", "programming_languages", on_delete: :cascade
add_foreign_key "analytics_language_trend_repository_languages", "projects", 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: "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", "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 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. ...@@ -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. > [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 Mentioning GitLab versions and tiers is important to all users and contributors
refers to versions of GitLab four or more major versions back, consider removing the text. 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 For example, if the current major version is 12.x, version text referencing versions of GitLab 8.x
and older are candidates for removal. and older are candidates for removal if necessary for clearer or cleaner docs.
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.
## Product badges ## Product badges
...@@ -1103,6 +1122,8 @@ The tier should be ideally added to headers, so that the full badge will be disp ...@@ -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, 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. 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: For example:
- `**(STARTER)**` renders as **(STARTER)** - `**(STARTER)**` renders as **(STARTER)**
......
...@@ -174,14 +174,14 @@ sequenceDiagram ...@@ -174,14 +174,14 @@ sequenceDiagram
c ->>+w: POST /some/url/upload c ->>+w: POST /some/url/upload
w->>+s: save the incoming file on a temporary location w->>+s: save the incoming file on a temporary location
s-->>-w: s-->>-w: request result
w->>+r: POST /some/url/upload w->>+r: POST /some/url/upload
Note over w,r: file was replaced with its location<br>and other metadata Note over w,r: file was replaced with its location<br>and other metadata
opt requires async processing opt requires async processing
r->>+redis: schedule a job r->>+redis: schedule a job
redis-->>-r: redis-->>-r: job is scheduled
end end
r-->>-c: request result r-->>-c: request result
...@@ -208,9 +208,11 @@ This is the more advanced acceleration technique we have in place. ...@@ -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. 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, In this setup, an extra Rails route must be implemented in order to handle authorization. Examples of this can be found in:
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). - [`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). **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. The answer to the `/authorize` call will only contain a file system path.
...@@ -231,17 +233,17 @@ sequenceDiagram ...@@ -231,17 +233,17 @@ sequenceDiagram
w->>+os: PUT file w->>+os: PUT file
Note over w,os: file is stored on a temporary location. Rails select the destination 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 w->>+r: POST /some/url/upload
Note over w,r: file was replaced with its location<br>and other metadata Note over w,r: file was replaced with its location<br>and other metadata
r->>+os: move object to final destination r->>+os: move object to final destination
os-->>-r: os-->>-r: request result
opt requires async processing opt requires async processing
r->>+redis: schedule a job r->>+redis: schedule a job
redis-->>-r: redis-->>-r: job is scheduled
end end
r-->>-c: request result r-->>-c: request result
......
...@@ -9,4 +9,7 @@ You can import your existing repositories by providing the Git URL: ...@@ -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. Click **Create project** to begin the import process
1. Once complete, you will be redirected to your newly created project 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) ![Import project by repo URL](img/import_projects_from_repo_url.png)
# 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 ...@@ -147,11 +147,11 @@ module Gitlab
end end
def current_lock_timeout_in_ms def current_lock_timeout_in_ms
timing_configuration[current_iteration - 1][0].in_milliseconds Integer(timing_configuration[current_iteration - 1][0].in_milliseconds)
end end
def current_sleep_time_in_seconds 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 end
end end
......
...@@ -10431,6 +10431,9 @@ msgstr "" ...@@ -10431,6 +10431,9 @@ msgstr ""
msgid "Invalid date format. Please use UTC format as YYYY-MM-DD" msgid "Invalid date format. Please use UTC format as YYYY-MM-DD"
msgstr "" msgstr ""
msgid "Invalid date range"
msgstr ""
msgid "Invalid feature" msgid "Invalid feature"
msgstr "" msgstr ""
......
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`] = ` ...@@ -69,9 +69,8 @@ exports[`Dashboard template matches the default snapshot 1`] = `
label-size="sm" label-size="sm"
> >
<date-time-picker-stub <date-time-picker-stub
end="2020-01-01T18:57:47.000Z" options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
start="2020-01-01T18:27:47.000Z" value="[object Object]"
timewindows="[object Object]"
/> />
</gl-form-group-stub> </gl-form-group-stub>
......
...@@ -5,13 +5,7 @@ import Dashboard from '~/monitoring/components/dashboard.vue'; ...@@ -5,13 +5,7 @@ import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
import { propsData } from '../init_utils'; import { propsData } from '../init_utils';
jest.mock('~/lib/utils/url_utility', () => ({ 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 [];
}),
}));
describe('Dashboard template', () => { describe('Dashboard template', () => {
let wrapper; let wrapper;
......
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);
});
});
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);
});
});
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`] = ` ...@@ -90,11 +90,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
type="button" type="button"
> >
<svg <svg
aria-hidden="true" class="gl-icon s16"
class="s16 ic-duplicate"
> >
<use <use
xlink:href="#duplicate" href="#copy-to-clipboard"
/> />
</svg> </svg>
</button> </button>
...@@ -128,11 +127,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` ...@@ -128,11 +127,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
type="button" type="button"
> >
<svg <svg
aria-hidden="true" class="gl-icon s16"
class="s16 ic-duplicate"
> >
<use <use
xlink:href="#duplicate" href="#copy-to-clipboard"
/> />
</svg> </svg>
</button> </button>
...@@ -158,11 +156,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` ...@@ -158,11 +156,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
type="button" type="button"
> >
<svg <svg
aria-hidden="true" class="gl-icon s16"
class="s16 ic-duplicate"
> >
<use <use
xlink:href="#duplicate" href="#copy-to-clipboard"
/> />
</svg> </svg>
</button> </button>
......
import SnippetApp from '~/snippets/components/app.vue'; import SnippetApp from '~/snippets/components/app.vue';
import SnippetHeader from '~/snippets/components/snippet_header.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 { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
...@@ -35,8 +37,10 @@ describe('Snippet view app', () => { ...@@ -35,8 +37,10 @@ describe('Snippet view app', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); 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(); createComponent();
expect(wrapper.find(SnippetHeader).exists()).toBe(true); expect(wrapper.find(SnippetHeader).exists()).toBe(true);
expect(wrapper.find(SnippetTitle).exists()).toBe(true);
expect(wrapper.find(SnippetBlob).exists()).toBe(true);
}); });
}); });
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);
});
});
import { shallowMount } from '@vue/test-utils'; 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 ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
describe('clipboard button', () => { describe('clipboard button', () => {
let wrapper; let wrapper;
...@@ -29,7 +28,7 @@ describe('clipboard button', () => { ...@@ -29,7 +28,7 @@ describe('clipboard button', () => {
it('renders a button for clipboard', () => { it('renders a button for clipboard', () => {
expect(wrapper.find(GlButton).exists()).toBe(true); expect(wrapper.find(GlButton).exists()).toBe(true);
expect(wrapper.attributes('data-clipboard-text')).toBe('copy me'); 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', () => { it('should have a tooltip with default values', () => {
......
...@@ -54,97 +54,6 @@ describe('date time picker lib', () => { ...@@ -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', () => { describe('stringToISODate', () => {
['', 'null', undefined, 'abc'].forEach(input => { ['', 'null', undefined, 'abc'].forEach(input => {
it(`throws error for invalid input like ${input}`, done => { it(`throws error for invalid input like ${input}`, done => {
......
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
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 optionsCount = defaultTimeRanges.length;
const start = '2019-10-10T07:00:00.000Z';
const end = '2019-10-13T07:00:00.000Z';
const selectedTimeWindowText = `3 days`;
describe('DateTimePicker', () => { describe('DateTimePicker', () => {
let dateTimePicker; let dateTimePicker;
...@@ -15,19 +15,10 @@ describe('DateTimePicker', () => { ...@@ -15,19 +15,10 @@ describe('DateTimePicker', () => {
const applyButtonElement = () => dateTimePicker.find('button.btn-success').element; const applyButtonElement = () => dateTimePicker.find('button.btn-success').element;
const findQuickRangeItems = () => dateTimePicker.findAll('.dropdown-item'); const findQuickRangeItems = () => dateTimePicker.findAll('.dropdown-item');
const cancelButtonElement = () => dateTimePicker.find('button.btn-secondary').element; 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 => { const createComponent = props => {
dateTimePicker = mount(DateTimePicker, { dateTimePicker = mount(DateTimePicker, {
propsData: { propsData: {
start,
end,
...props, ...props,
}, },
}); });
...@@ -40,7 +31,7 @@ describe('DateTimePicker', () => { ...@@ -40,7 +31,7 @@ describe('DateTimePicker', () => {
it('renders dropdown toggle button with selected text', done => { it('renders dropdown toggle button with selected text', done => {
createComponent(); createComponent();
dateTimePicker.vm.$nextTick(() => { dateTimePicker.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe(selectedTimeWindowText); expect(dropdownToggle().text()).toBe(defaultTimeRange.label);
done(); done();
}); });
}); });
...@@ -54,8 +45,10 @@ describe('DateTimePicker', () => { ...@@ -54,8 +45,10 @@ describe('DateTimePicker', () => {
it('renders inputs with h/m/s truncated if its all 0s', done => { it('renders inputs with h/m/s truncated if its all 0s', done => {
createComponent({ createComponent({
start: '2019-10-10T00:00:00.000Z', value: {
end: '2019-10-14T00:10:00.000Z', start: '2019-10-10T00:00:00.000Z',
end: '2019-10-14T00:10:00.000Z',
},
}); });
dateTimePicker.vm.$nextTick(() => { dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10'); expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10');
...@@ -64,22 +57,21 @@ describe('DateTimePicker', () => { ...@@ -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(); createComponent();
dropdownToggle().trigger('click'); dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => { dateTimePicker.vm.$nextTick(() => {
expect(findQuickRangeItems().length).toBe(timeWindowsCount); expect(findQuickRangeItems().length).toBe(optionsCount);
done(); done();
}); });
}); });
it(`renders dropdown with correct quick range item selected`, done => { it('renders dropdown with a default quick range item selected', done => {
createComponent(); createComponent();
dropdownToggle().trigger('click'); dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => { dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(selectedTimeWindowText); expect(dateTimePicker.find('.dropdown-item.active').exists()).toBe(true);
expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(defaultTimeRange.label);
expect(dateTimePicker.find('.dropdown-item.active svg').isVisible()).toBe(true);
done(); done();
}); });
}); });
...@@ -92,99 +84,142 @@ describe('DateTimePicker', () => { ...@@ -92,99 +84,142 @@ describe('DateTimePicker', () => {
expect(applyButtonElement().getAttribute('disabled')).toBe('disabled'); expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
}); });
it('displays inline error message if custom time range inputs are invalid', done => { describe('user input', () => {
createComponent(); const fillInputAndBlur = (input, val) => {
fillInputAndBlur('#custom-time-from', '2019-10-01abc') dateTimePicker.find(input).setValue(val);
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-10abc')) return dateTimePicker.vm.$nextTick().then(() => {
.then(() => { dateTimePicker.find(input).trigger('blur');
expect(dateTimePicker.findAll('.invalid-feedback').length).toBe(2); return dateTimePicker.vm.$nextTick();
done(); });
}) };
.catch(done);
});
it('keeps apply button disabled with invalid custom time range inputs', done => { beforeEach(done => {
createComponent(); createComponent();
fillInputAndBlur('#custom-time-from', '2019-10-01abc') dateTimePicker.vm.$nextTick(done);
.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 => { it('displays inline error message if custom time range inputs are invalid', done => {
createComponent(); fillInputAndBlur('#custom-time-from', '2019-10-01abc')
fillInputAndBlur('#custom-time-from', '2019-10-01') .then(() => fillInputAndBlur('#custom-time-to', '2019-10-10abc'))
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) .then(() => {
.then(() => { expect(dateTimePicker.findAll('.invalid-feedback').length).toBe(2);
expect(applyButtonElement().getAttribute('disabled')).toBeNull(); done();
done(); })
}) .catch(done);
.catch(done.fail); });
});
it('emits dates in an object when apply is clicked', done => { it('keeps apply button disabled with invalid custom time range inputs', done => {
createComponent(); fillInputAndBlur('#custom-time-from', '2019-10-01abc')
fillInputAndBlur('#custom-time-from', '2019-10-01') .then(() => fillInputAndBlur('#custom-time-to', '2019-09-19'))
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) .then(() => {
.then(() => { expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
applyButtonElement().click(); done();
})
expect(dateTimePicker.emitted().apply).toHaveLength(1); .catch(done);
expect(dateTimePicker.emitted().apply[0]).toEqual([ });
{
end: '2019-10-19T00:00:00Z',
start: '2019-10-01T00:00:00Z',
},
]);
done();
})
.catch(done.fail);
});
it('hides the popover with cancel button', done => { it('enables apply button with valid custom time range inputs', done => {
createComponent(); fillInputAndBlur('#custom-time-from', '2019-10-01')
dropdownToggle().trigger('click'); .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.then(() => {
expect(applyButtonElement().getAttribute('disabled')).toBeNull();
done();
})
.catch(done.fail);
});
dateTimePicker.vm.$nextTick(() => { it('emits dates in an object when apply is clicked', done => {
cancelButtonElement().click(); fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.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);
});
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(() => { dateTimePicker.vm.$nextTick(() => {
expect(dropdownMenu().classes('show')).toBe(false); cancelButtonElement().click();
done();
dateTimePicker.vm.$nextTick(() => {
expect(dropdownMenu().classes('show')).toBe(false);
done();
});
}); });
}); });
}); });
describe('when using non-default time windows', () => { describe('when using non-default time windows', () => {
const otherTimeWindows = { const MOCK_NOW = Date.UTC(2020, 0, 23, 20);
oneMinute: {
const otherTimeRanges = [
{
label: '1 minute', label: '1 minute',
seconds: 60, duration: { seconds: 60 },
}, },
twoMinutes: { {
label: '2 minutes', label: '2 minutes',
seconds: 60 * 2, duration: { seconds: 60 * 2 },
default: true, default: true,
}, },
fiveMinutes: { {
label: '5 minutes', 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 => { it('renders dropdown with a label in the quick range', done => {
createComponent({ createComponent({
// 2 minutes range value: {
start: '2020-01-21T15:00:00.000Z', duration: { seconds: 60 * 5 },
end: '2020-01-21T15:02:00.000Z', },
timeWindows: otherTimeWindows, options: otherTimeRanges,
}); });
dropdownToggle().trigger('click'); dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => { dateTimePicker.vm.$nextTick(() => {
expect(dropdownToggle().text()).toBe('2 minutes'); expect(dropdownToggle().text()).toBe('5 minutes');
done(); done();
}); });
...@@ -192,16 +227,16 @@ describe('DateTimePicker', () => { ...@@ -192,16 +227,16 @@ describe('DateTimePicker', () => {
it('renders dropdown with quick range items', done => { it('renders dropdown with quick range items', done => {
createComponent({ createComponent({
// 2 minutes range value: {
start: '2020-01-21T15:00:00.000Z', duration: { seconds: 60 * 2 },
end: '2020-01-21T15:02:00.000Z', },
timeWindows: otherTimeWindows, options: otherTimeRanges,
}); });
dropdownToggle().trigger('click'); dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => { dateTimePicker.vm.$nextTick(() => {
const items = findQuickRangeItems(); 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).text()).toBe('1 minute');
expect(items.at(0).is('.active')).toBe(false); expect(items.at(0).is('.active')).toBe(false);
...@@ -217,14 +252,13 @@ describe('DateTimePicker', () => { ...@@ -217,14 +252,13 @@ describe('DateTimePicker', () => {
it('renders dropdown with a label not in the quick range', done => { it('renders dropdown with a label not in the quick range', done => {
createComponent({ createComponent({
// 10 minutes range value: {
start: '2020-01-21T15:00:00.000Z', duration: { seconds: 60 * 4 },
end: '2020-01-21T15:10:00.000Z', },
timeWindows: otherTimeWindows,
}); });
dropdownToggle().trigger('click'); dropdownToggle().trigger('click');
dateTimePicker.vm.$nextTick(() => { 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(); done();
}); });
......
# 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 ...@@ -128,4 +128,23 @@ describe Gitlab::Database::WithLockRetries do
end end
end 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 end
# 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| ...@@ -26,3 +26,26 @@ RSpec::Matchers.define :be_scheduled_migration do |*expected|
"Migration `#{migration}` with args `#{expected.inspect}` not scheduled!" "Migration `#{migration}` with args `#{expected.inspect}` not scheduled!"
end end
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
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment