diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-01-31 15:08:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-01-31 15:08:42 +0000 |
commit | c27acb1d376f7127cd33eadcc8f5683ed55262bc (patch) | |
tree | 685c31391dca71a73782b5c8626f4ef5b582dc21 | |
parent | 1808454313ed75c92e1384466e8c83bfbc8ae25e (diff) | |
download | gitlab-ce-c27acb1d376f7127cd33eadcc8f5683ed55262bc.tar.gz |
Add latest changes from gitlab-org/gitlab@master
46 files changed, 1038 insertions, 536 deletions
@@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 58a4f1ad53d..e61068bbf7f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/assets/javascripts/blob/components/blob_embeddable.vue b/app/assets/javascripts/blob/components/blob_embeddable.vue new file mode 100644 index 00000000000..26bd0208309 --- /dev/null +++ b/app/assets/javascripts/blob/components/blob_embeddable.vue @@ -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> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index c76c039fb3b..5f410c487e9 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -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> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index c79e43c7c29..e55de1c0105 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -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); }, diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 2d5361a5029..789b3131d11 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -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); diff --git a/app/assets/javascripts/snippets/components/app.vue b/app/assets/javascripts/snippets/components/app.vue index 7a2145a800c..e98f56d87f5 100644 --- a/app/assets/javascripts/snippets/components/app.vue +++ b/app/assets/javascripts/snippets/components/app.vue @@ -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> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue new file mode 100644 index 00000000000..b91e08a4251 --- /dev/null +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -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> diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js new file mode 100644 index 00000000000..87e3fe360a3 --- /dev/null +++ b/app/assets/javascripts/snippets/constants.js @@ -0,0 +1,3 @@ +export const SNIPPET_VISIBILITY_PRIVATE = 'private'; +export const SNIPPET_VISIBILITY_INTERNAL = 'internal'; +export const SNIPPET_VISIBILITY_PUBLIC = 'public'; diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 9f498037185..3ff1d9cf48a 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -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> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index 7d4c162473f..eedcafe2b42 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -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> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js index 685115b92dd..673d981cf07 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js @@ -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'", @@ -68,46 +60,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. * * @param {string} val diff --git a/changelogs/unreleased/197879-add-relative-time-ranges-to-datepicker.yml b/changelogs/unreleased/197879-add-relative-time-ranges-to-datepicker.yml new file mode 100644 index 00000000000..ebdfc9549b8 --- /dev/null +++ b/changelogs/unreleased/197879-add-relative-time-ranges-to-datepicker.yml @@ -0,0 +1,5 @@ +--- +title: Allow for relative time ranges in metrics dashboard URLs +merge_request: 23765 +author: +type: added diff --git a/changelogs/unreleased/198313-remove-code-hotspots-tables.yml b/changelogs/unreleased/198313-remove-code-hotspots-tables.yml new file mode 100644 index 00000000000..c273b7a74c1 --- /dev/null +++ b/changelogs/unreleased/198313-remove-code-hotspots-tables.yml @@ -0,0 +1,5 @@ +--- +title: Remove unused Code Hotspots database tables +merge_request: 23590 +author: +type: other diff --git a/changelogs/unreleased/22406-update-existing-subgroups-to-match-visibility-level-of-parent-v2.yml b/changelogs/unreleased/22406-update-existing-subgroups-to-match-visibility-level-of-parent-v2.yml new file mode 100644 index 00000000000..d86c403dccb --- /dev/null +++ b/changelogs/unreleased/22406-update-existing-subgroups-to-match-visibility-level-of-parent-v2.yml @@ -0,0 +1,5 @@ +--- +title: Fix visibility levels of subgroups to be not higher than their parents' level +merge_request: 22889 +author: +type: other diff --git a/changelogs/unreleased/dmishunov-update-clipboard-icon.yml b/changelogs/unreleased/dmishunov-update-clipboard-icon.yml new file mode 100644 index 00000000000..38232656048 --- /dev/null +++ b/changelogs/unreleased/dmishunov-update-clipboard-icon.yml @@ -0,0 +1,5 @@ +--- +title: Updated icon for copy-to-clipboard button +merge_request: 24146 +author: +type: other diff --git a/db/migrate/20200123090839_remove_analytics_repository_table_fks_on_projects.rb b/db/migrate/20200123090839_remove_analytics_repository_table_fks_on_projects.rb new file mode 100644 index 00000000000..a591596c74c --- /dev/null +++ b/db/migrate/20200123090839_remove_analytics_repository_table_fks_on_projects.rb @@ -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 diff --git a/db/migrate/20200123091422_remove_analytics_repository_files_fk_on_other_analytics_tables.rb b/db/migrate/20200123091422_remove_analytics_repository_files_fk_on_other_analytics_tables.rb new file mode 100644 index 00000000000..60de2e9cb7f --- /dev/null +++ b/db/migrate/20200123091422_remove_analytics_repository_files_fk_on_other_analytics_tables.rb @@ -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 diff --git a/db/migrate/20200123091622_drop_analytics_repository_files_table.rb b/db/migrate/20200123091622_drop_analytics_repository_files_table.rb new file mode 100644 index 00000000000..aa31d23920a --- /dev/null +++ b/db/migrate/20200123091622_drop_analytics_repository_files_table.rb @@ -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 diff --git a/db/migrate/20200123091734_drop_analytics_repository_file_commits_table.rb b/db/migrate/20200123091734_drop_analytics_repository_file_commits_table.rb new file mode 100644 index 00000000000..2d3c1c9a817 --- /dev/null +++ b/db/migrate/20200123091734_drop_analytics_repository_file_commits_table.rb @@ -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 diff --git a/db/migrate/20200123091854_drop_analytics_repository_file_edits_table.rb b/db/migrate/20200123091854_drop_analytics_repository_file_edits_table.rb new file mode 100644 index 00000000000..3bb026727f4 --- /dev/null +++ b/db/migrate/20200123091854_drop_analytics_repository_file_edits_table.rb @@ -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 diff --git a/db/post_migrate/20200110121314_schedule_update_existing_subgroup_to_match_visibility_level_of_parent.rb b/db/post_migrate/20200110121314_schedule_update_existing_subgroup_to_match_visibility_level_of_parent.rb new file mode 100644 index 00000000000..813cd600ddc --- /dev/null +++ b/db/post_migrate/20200110121314_schedule_update_existing_subgroup_to_match_visibility_level_of_parent.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 80e7af66fb9..70e93a63148 100644 --- a/db/schema.rb +++ b/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 diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md index 225e3a65eab..9c4b97c4adf 100644 --- a/doc/development/documentation/styleguide.md +++ b/doc/development/documentation/styleguide.md @@ -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 - -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. +### Importance of referencing GitLab versions and tiers + +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)** diff --git a/doc/development/uploads.md b/doc/development/uploads.md index 3eda0667753..b26211901b2 100644 --- a/doc/development/uploads.md +++ b/doc/development/uploads.md @@ -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 diff --git a/doc/user/project/import/repo_by_url.md b/doc/user/project/import/repo_by_url.md index c20b1cb7f5e..96debcb00a8 100644 --- a/doc/user/project/import/repo_by_url.md +++ b/doc/user/project/import/repo_by_url.md @@ -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) diff --git a/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent.rb b/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent.rb new file mode 100644 index 00000000000..9e330f7c008 --- /dev/null +++ b/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent.rb @@ -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 diff --git a/lib/gitlab/database/with_lock_retries.rb b/lib/gitlab/database/with_lock_retries.rb index 37f7e8fbdac..2f36bfa1480 100644 --- a/lib/gitlab/database/with_lock_retries.rb +++ b/lib/gitlab/database/with_lock_retries.rb @@ -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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b8d04d6e6fd..71043671be7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/frontend/blob/components/blob_embeddable_spec.js b/spec/frontend/blob/components/blob_embeddable_spec.js new file mode 100644 index 00000000000..b2fe71f1401 --- /dev/null +++ b/spec/frontend/blob/components/blob_embeddable_spec.js @@ -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 }, + ]); + }); +}); diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 2ed93396376..2b2ff074f83 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -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> diff --git a/spec/frontend/monitoring/components/dashboard_template_spec.js b/spec/frontend/monitoring/components/dashboard_template_spec.js index d525f4821f4..38523ab82bc 100644 --- a/spec/frontend/monitoring/components/dashboard_template_spec.js +++ b/spec/frontend/monitoring/components/dashboard_template_spec.js @@ -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; diff --git a/spec/frontend/monitoring/components/dashboard_time_url_spec.js b/spec/frontend/monitoring/components/dashboard_time_url_spec.js deleted file mode 100644 index 2da377eb79f..00000000000 --- a/spec/frontend/monitoring/components/dashboard_time_url_spec.js +++ /dev/null @@ -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); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_time_window_spec.js b/spec/frontend/monitoring/components/dashboard_time_window_spec.js deleted file mode 100644 index e9f2a67983a..00000000000 --- a/spec/frontend/monitoring/components/dashboard_time_window_spec.js +++ /dev/null @@ -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); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js new file mode 100644 index 00000000000..33fbfac486f --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -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); + }); + }); +}); diff --git a/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap index c8482bf08ca..426bc5c0e6c 100644 --- a/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap +++ b/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap @@ -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> diff --git a/spec/frontend/snippets/components/app_spec.js b/spec/frontend/snippets/components/app_spec.js index 6576e5b075f..a683ed9aaba 100644 --- a/spec/frontend/snippets/components/app_spec.js +++ b/spec/frontend/snippets/components/app_spec.js @@ -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); }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js new file mode 100644 index 00000000000..8401c08b1da --- /dev/null +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -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); + }); +}); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index 37f71867ab9..07ff86828e7 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -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', () => { diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js index b7b024183e1..3a75ab2d127 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js @@ -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 => { diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js index 98dfbe9cd14..90130917d8f 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js @@ -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(); - - 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('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); + }); - it('hides the popover with cancel button', done => { - createComponent(); - dropdownToggle().trigger('click'); + 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); + }); - dateTimePicker.vm.$nextTick(() => { - cancelButtonElement().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(); + + 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(); }); diff --git a/spec/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb b/spec/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb new file mode 100644 index 00000000000..37280110b91 --- /dev/null +++ b/spec/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb @@ -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 diff --git a/spec/lib/gitlab/database/with_lock_retries_spec.rb b/spec/lib/gitlab/database/with_lock_retries_spec.rb index c3be6510584..b6321f2eab1 100644 --- a/spec/lib/gitlab/database/with_lock_retries_spec.rb +++ b/spec/lib/gitlab/database/with_lock_retries_spec.rb @@ -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 diff --git a/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb b/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb new file mode 100644 index 00000000000..5ff9ff4641f --- /dev/null +++ b/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb @@ -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 diff --git a/spec/support/matchers/background_migrations_matchers.rb b/spec/support/matchers/background_migrations_matchers.rb index c38aa7ad6a6..8735dac8b2a 100644 --- a/spec/support/matchers/background_migrations_matchers.rb +++ b/spec/support/matchers/background_migrations_matchers.rb @@ -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 diff --git a/spec/support/migrations_helpers/namespaces_helper.rb b/spec/support/migrations_helpers/namespaces_helper.rb new file mode 100644 index 00000000000..4ca01c87568 --- /dev/null +++ b/spec/support/migrations_helpers/namespaces_helper.rb @@ -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 |