Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-09-15 21:11:56 +00:00
parent 7a15fb07cf
commit d30dfdfd05
30 changed files with 82 additions and 2098 deletions

View File

@ -3,15 +3,6 @@ export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250;
export const THOUSAND = 1000;
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
export const DATETIME_RANGE_TYPES = {
fixed: 'fixed',
anchored: 'anchored',
rolling: 'rolling',
open: 'open',
invalid: 'invalid',
};
export const BV_SHOW_MODAL = 'bv::show::modal';
export const BV_HIDE_MODAL = 'bv::hide::modal';
export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip';

View File

@ -1,25 +1,4 @@
import { pick, omit, isEqual, isEmpty } from 'lodash';
import dateformat from '~/lib/dateformat';
import { DATETIME_RANGE_TYPES } from './constants';
import { secondsToMilliseconds } from './datetime_utility';
const MINIMUM_DATE = new Date(0);
const DEFAULT_DIRECTION = 'before';
const durationToMillis = (duration) => {
if (Object.entries(duration).length === 1 && Number.isFinite(duration.seconds)) {
return secondsToMilliseconds(duration.seconds);
}
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Invalid duration: only `seconds` is supported');
};
const dateMinusDuration = (date, duration) => new Date(date.getTime() - durationToMillis(duration));
const datePlusDuration = (date, duration) => new Date(date.getTime() + durationToMillis(duration));
const isValidDuration = (duration) => Boolean(duration && Number.isFinite(duration.seconds));
export const isValidDateString = (dateString) => {
if (typeof dateString !== 'string' || !dateString.trim()) {
@ -38,291 +17,3 @@ export const isValidDateString = (dateString) => {
}
return !Number.isNaN(Date.parse(isoFormatted));
};
const handleRangeDirection = ({ direction = DEFAULT_DIRECTION, anchorDate, minDate, maxDate }) => {
let startDate;
let endDate;
if (direction === DEFAULT_DIRECTION) {
startDate = minDate;
endDate = anchorDate;
} else {
startDate = anchorDate;
endDate = maxDate;
}
return {
startDate,
endDate,
};
};
/**
* Converts a fixed range to a fixed range
* @param {Object} fixedRange - A range with fixed start and
* end (e.g. "midnight January 1st 2020 to midday January31st 2020")
*/
const convertFixedToFixed = ({ start, end }) => ({
start,
end,
});
/**
* Converts an anchored range to a fixed range
* @param {Object} anchoredRange - A duration of time
* relative to a fixed point in time (e.g., "the 30 minutes
* before midnight January 1st 2020", or "the 2 days
* after midday on the 11th of May 2019")
*/
const convertAnchoredToFixed = ({ anchor, duration, direction }) => {
const anchorDate = new Date(anchor);
const { startDate, endDate } = handleRangeDirection({
minDate: dateMinusDuration(anchorDate, duration),
maxDate: datePlusDuration(anchorDate, duration),
direction,
anchorDate,
});
return {
start: startDate.toISOString(),
end: endDate.toISOString(),
};
};
/**
* Converts a rolling change to a fixed range
*
* @param {Object} rollingRange - A time range relative to
* now (e.g., "last 2 minutes", or "next 2 days")
*/
const convertRollingToFixed = ({ duration, direction }) => {
// Use Date.now internally for easier mocking in tests
const now = new Date(Date.now());
return convertAnchoredToFixed({
duration,
direction,
anchor: now.toISOString(),
});
};
/**
* Converts an open range to a fixed range
*
* @param {Object} openRange - A time range relative
* to an anchor (e.g., "before midnight on the 1st of
* January 2020", or "after midday on the 11th of May 2019")
*/
const convertOpenToFixed = ({ anchor, direction }) => {
// Use Date.now internally for easier mocking in tests
const now = new Date(Date.now());
const { startDate, endDate } = handleRangeDirection({
minDate: MINIMUM_DATE,
maxDate: now,
direction,
anchorDate: new Date(anchor),
});
return {
start: startDate.toISOString(),
end: endDate.toISOString(),
};
};
/**
* Handles invalid date ranges
*/
const handleInvalidRange = () => {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('The input range does not have the right format.');
};
const handlers = {
invalid: handleInvalidRange,
fixed: convertFixedToFixed,
anchored: convertAnchoredToFixed,
rolling: convertRollingToFixed,
open: convertOpenToFixed,
};
/**
* Validates and returns the type of range
*
* @param {Object} Date time range
* @returns {String} `key` value for one of the handlers
*/
export function getRangeType(range) {
const { start, end, anchor, duration } = range;
if ((start || end) && !anchor && !duration) {
return isValidDateString(start) && isValidDateString(end)
? DATETIME_RANGE_TYPES.fixed
: DATETIME_RANGE_TYPES.invalid;
}
if (anchor && duration) {
return isValidDateString(anchor) && isValidDuration(duration)
? DATETIME_RANGE_TYPES.anchored
: DATETIME_RANGE_TYPES.invalid;
}
if (duration && !anchor) {
return isValidDuration(duration) ? DATETIME_RANGE_TYPES.rolling : DATETIME_RANGE_TYPES.invalid;
}
if (anchor && !duration) {
return isValidDateString(anchor) ? DATETIME_RANGE_TYPES.open : DATETIME_RANGE_TYPES.invalid;
}
return DATETIME_RANGE_TYPES.invalid;
}
/**
* convertToFixedRange Transforms a `range of time` into a `fixed range of time`.
*
* The following types of a `ranges of time` can be represented:
*
* Fixed Range: A range with fixed start and end (e.g. "midnight January 1st 2020 to midday January 31st 2020")
* Anchored Range: A duration of time relative to a fixed point in time (e.g., "the 30 minutes before midnight January 1st 2020", or "the 2 days after midday on the 11th of May 2019")
* Rolling Range: A time range relative to now (e.g., "last 2 minutes", or "next 2 days")
* Open Range: A time range relative to an anchor (e.g., "before midnight on the 1st of January 2020", or "after midday on the 11th of May 2019")
*
* @param {Object} dateTimeRange - A Time Range representation
* It contains the data needed to create a fixed time range plus
* a label (recommended) to indicate the range that is covered.
*
* A definition via a TypeScript notation is presented below:
*
*
* type Duration = { // A duration of time, always in seconds
* seconds: number;
* }
*
* type Direction = 'before' | 'after'; // Direction of time relative to an anchor
*
* type FixedRange = {
* start: ISO8601;
* end: ISO8601;
* label: string;
* }
*
* type AnchoredRange = {
* anchor: ISO8601;
* duration: Duration;
* direction: Direction; // defaults to 'before'
* label: string;
* }
*
* type RollingRange = {
* duration: Duration;
* direction: Direction; // defaults to 'before'
* label: string;
* }
*
* type OpenRange = {
* anchor: ISO8601;
* direction: Direction; // defaults to 'before'
* label: string;
* }
*
* type DateTimeRange = FixedRange | AnchoredRange | RollingRange | OpenRange;
*
*
* @returns {FixedRange} An object with a start and end in ISO8601 format.
*/
export const convertToFixedRange = (dateTimeRange) =>
handlers[getRangeType(dateTimeRange)](dateTimeRange);
/**
* Returns a copy of the object only with time range
* properties relevant to time range calculation.
*
* Filtered properties are:
* - 'start'
* - 'end'
* - 'anchor'
* - 'duration'
* - 'direction': if direction is already the default, its removed.
*
* @param {Object} timeRange - A time range object
* @returns Copy of time range
*/
const pruneTimeRange = (timeRange) => {
const res = pick(timeRange, ['start', 'end', 'anchor', 'duration', 'direction']);
if (res.direction === DEFAULT_DIRECTION) {
return omit(res, 'direction');
}
return res;
};
/**
* Returns true if the time ranges are equal according to
* the time range calculation properties
*
* @param {Object} timeRange - A time range object
* @param {Object} other - Time range object to compare with.
* @returns true if the time ranges are equal, false otherwise
*/
export const isEqualTimeRanges = (timeRange, other) => {
const tr1 = pruneTimeRange(timeRange);
const tr2 = pruneTimeRange(other);
return isEqual(tr1, tr2);
};
/**
* Searches for a time range in a array of time ranges using
* only the properies relevant to time ranges calculation.
*
* @param {Object} timeRange - Time range to search (needle)
* @param {Array} timeRanges - Array of time tanges (haystack)
*/
export const findTimeRange = (timeRange, timeRanges) =>
timeRanges.find((element) => isEqualTimeRanges(element, timeRange));
// Time Ranges as URL Parameters Utils
/**
* List of possible time ranges parameters
*/
export const timeRangeParamNames = ['start', 'end', 'anchor', 'duration_seconds', 'direction'];
/**
* Converts a valid time range to a flat key-value pairs object.
*
* Duration is flatted to avoid having nested objects.
*
* @param {Object} A time range
* @returns key-value pairs object that can be used as parameters in a URL.
*/
export const timeRangeToParams = (timeRange) => {
let params = pruneTimeRange(timeRange);
if (timeRange.duration) {
const durationParms = {};
Object.keys(timeRange.duration).forEach((key) => {
durationParms[`duration_${key}`] = timeRange.duration[key].toString();
});
params = { ...durationParms, ...params };
params = omit(params, 'duration');
}
return params;
};
/**
* Converts a valid set of flat params to a time range object
*
* Parameters that are not part of time range object are ignored.
*
* @param {params} params - key-value pairs object.
*/
export const timeRangeFromParams = (params) => {
const timeRangeParams = pick(params, timeRangeParamNames);
let range = Object.entries(timeRangeParams).reduce((acc, [key, val]) => {
// unflatten duration
if (key.startsWith('duration_')) {
acc.duration = acc.duration || {};
acc.duration[key.slice('duration_'.length)] = parseInt(val, 10);
return acc;
}
return { [key]: val, ...acc };
}, {});
range = pruneTimeRange(range);
return !isEmpty(range) ? range : null;
};

View File

@ -104,10 +104,8 @@ export default {
},
},
mounted() {
if (this.glFeatures.superSidebarFlyoutMenus) {
this.decideFlyoutState();
window.addEventListener('resize', this.decideFlyoutState);
}
this.decideFlyoutState();
window.addEventListener('resize', this.decideFlyoutState);
},
beforeDestroy() {
window.removeEventListener('resize', this.decideFlyoutState);

View File

@ -1,283 +0,0 @@
<script>
import { GlIcon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
import { __, sprintf } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
defaultTimeRanges,
defaultTimeRange,
isValidInputString,
inputStringToIsoDate,
isoDateToInputString,
} from './date_time_picker_lib';
const events = {
input: 'input',
invalid: 'invalid',
};
export default {
components: {
GlIcon,
GlButton,
GlDropdown,
GlDropdownItem,
GlFormGroup,
TooltipOnTruncate,
DateTimePickerInput,
},
props: {
value: {
type: Object,
required: false,
default: () => defaultTimeRange,
},
options: {
type: Array,
required: false,
default: () => defaultTimeRanges,
},
customEnabled: {
type: Boolean,
required: false,
default: true,
},
utc: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
timeRange: this.value,
/**
* Valid start iso date string, null if not valid value
*/
startDate: null,
/**
* Invalid start date string as input by the user
*/
startFallbackVal: '',
/**
* Valid end iso date string, null if not valid value
*/
endDate: null,
/**
* Invalid end date string as input by the user
*/
endFallbackVal: '',
};
},
computed: {
startInputValid() {
return isValidInputString(this.startDate);
},
endInputValid() {
return isValidInputString(this.endDate);
},
isValid() {
return this.startInputValid && this.endInputValid;
},
startInput: {
get() {
return this.dateToInput(this.startDate) || this.startFallbackVal;
},
set(val) {
try {
this.startDate = this.inputToDate(val);
this.startFallbackVal = null;
} catch (e) {
this.startDate = null;
this.startFallbackVal = val;
}
this.timeRange = null;
},
},
endInput: {
get() {
return this.dateToInput(this.endDate) || this.endFallbackVal;
},
set(val) {
try {
this.endDate = this.inputToDate(val);
this.endFallbackVal = null;
} catch (e) {
this.endDate = null;
this.endFallbackVal = val;
}
this.timeRange = null;
},
},
timeWindowText() {
try {
const timeRange = findTimeRange(this.value, this.options);
if (timeRange) {
return timeRange.label;
}
const { start, end } = convertToFixedRange(this.value);
if (isValidInputString(start) && isValidInputString(end)) {
return sprintf(__('%{start} to %{end}'), {
start: this.stripZerosInDateTime(this.dateToInput(start)),
end: this.stripZerosInDateTime(this.dateToInput(end)),
});
}
} catch {
return __('Invalid date range');
}
return '';
},
customLabel() {
if (this.utc) {
return __('Custom range (UTC)');
}
return __('Custom range');
},
},
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);
}
},
methods: {
dateToInput(date) {
if (date === null) {
return null;
}
return isoDateToInputString(date, this.utc);
},
inputToDate(value) {
return inputStringToIsoDate(value, this.utc);
},
stripZerosInDateTime(str = '') {
return str.replace(' 00:00:00', '');
},
closeDropdown() {
this.$refs.dropdown.hide();
},
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);
},
},
};
</script>
<template>
<tooltip-on-truncate
:title="timeWindowText"
:truncate-target="(elem) => elem.querySelector('.gl-dropdown-toggle-text')"
placement="top"
class="d-inline-block"
>
<gl-dropdown
ref="dropdown"
:text="timeWindowText"
v-bind="$attrs"
class="date-time-picker w-100"
menu-class="date-time-picker-menu"
toggle-class="date-time-picker-toggle text-truncate"
>
<template #button-content>
<span class="gl-flex-grow-1 text-truncate">{{ timeWindowText }}</span>
<span v-if="utc" class="gl-text-gray-500 gl-font-weight-bold gl-font-sm">{{
__('UTC')
}}</span>
<gl-icon class="gl-dropdown-caret" name="chevron-down" />
</template>
<div class="d-flex justify-content-between gl-p-2">
<gl-form-group
v-if="customEnabled"
:label="customLabel"
label-for="custom-from-time"
label-class="gl-pb-2"
class="custom-time-range-form-group col-md-7 gl-pl-2 gl-pr-0 m-0"
>
<div class="gl-pt-3">
<date-time-picker-input
id="custom-time-from"
v-model="startInput"
:label="__('From')"
:state="startInputValid"
/>
<date-time-picker-input
id="custom-time-to"
v-model="endInput"
:label="__('To')"
:state="endInputValid"
/>
</div>
<gl-form-group>
<gl-button data-testid="cancelButton" @click="closeDropdown">{{
__('Cancel')
}}</gl-button>
<gl-button
variant="confirm"
category="primary"
:disabled="!isValid"
@click="setFixedRange()"
>
{{ __('Apply') }}
</gl-button>
</gl-form-group>
</gl-form-group>
<gl-form-group label-for="group-id-dropdown" class="col-md-5 gl-px-2 m-0">
<template #label>
<span class="gl-pl-7">{{ __('Quick range') }}</span>
</template>
<gl-dropdown-item
v-for="(option, index) in options"
:key="index"
:active="isOptionActive(option)"
active-class="active"
@click="setQuickRange(option)"
>
<gl-icon
name="mobile-issue-close"
class="align-bottom"
:class="{ invisible: !isOptionActive(option) }"
/>
{{ option.label }}
</gl-dropdown-item>
</gl-form-group>
</div>
</gl-dropdown>
</tooltip-on-truncate>
</template>

View File

@ -1,77 +0,0 @@
<script>
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __, sprintf } from '~/locale';
import { dateFormats } from './date_time_picker_lib';
const inputGroupText = {
invalidFeedback: sprintf(__('Format: %{dateFormat}'), {
dateFormat: dateFormats.inputFormat,
}),
placeholder: dateFormats.inputFormat,
};
export default {
components: {
GlFormGroup,
GlFormInput,
},
props: {
state: {
default: null,
required: true,
validator: (prop) => typeof prop === 'boolean' || prop === null,
},
value: {
default: null,
required: false,
validator: (prop) => typeof prop === 'string' || prop === null,
},
label: {
type: String,
default: '',
required: true,
},
id: {
type: String,
required: false,
default: () => uniqueId('dateTimePicker_'),
},
},
data() {
return {
inputGroupText,
};
},
computed: {
invalidFeedback() {
return this.state ? '' : this.inputGroupText.invalidFeedback;
},
inputState() {
// When the state is valid we want to show no
// green outline. Hence passing null and not true.
if (this.state === true) {
return null;
}
return this.state;
},
},
methods: {
onInputBlur(e) {
this.$emit('input', e.target.value.trim() || null);
},
},
};
</script>
<template>
<gl-form-group :label="label" label-size="sm" :label-for="id" :invalid-feedback="invalidFeedback">
<gl-form-input
:id="id"
:value="value"
:state="inputState"
:placeholder="inputGroupText.placeholder"
@blur="onInputBlur"
/>
</gl-form-group>
</template>

View File

@ -1,91 +0,0 @@
import dateformat from '~/lib/dateformat';
import { __ } from '~/locale';
/**
* Default time ranges for the date picker.
* @see app/assets/javascripts/lib/utils/datetime_range.js
*/
export const defaultTimeRanges = [
{
duration: { seconds: 60 * 30 },
label: __('30 minutes'),
},
{
duration: { seconds: 60 * 60 * 3 },
label: __('3 hours'),
},
{
duration: { seconds: 60 * 60 * 8 },
label: __('8 hours'),
default: true,
},
{
duration: { seconds: 60 * 60 * 24 * 1 },
label: __('1 day'),
},
];
export const defaultTimeRange = defaultTimeRanges.find((tr) => tr.default);
export const dateFormats = {
/**
* Format used by users to input dates
*
* Note: Should be a format that can be parsed by Date.parse.
*/
inputFormat: 'yyyy-mm-dd HH:MM:ss',
/**
* Format used to strip timezone from inputs
*/
stripTimezoneFormat: "yyyy-mm-dd'T'HH:MM:ss'Z'",
};
/**
* Returns true if the date can be parsed succesfully after
* being typed by a user.
*
* It allows some ambiguity so validation is not strict.
*
* @param {string} value - Value as typed by the user
* @returns true if the value can be parsed as a valid date, false otherwise
*/
export const isValidInputString = (value) => {
try {
// dateformat throws error that can be caught.
// This is better than using `new Date()`
if (value && value.trim()) {
dateformat(value, 'isoDateTime');
return true;
}
return false;
} catch (e) {
return false;
}
};
/**
* Convert the input in time picker component to an ISO date.
*
* @param {string} value
* @param {Boolean} utc - If true, it forces the date to by
* formatted using UTC format, ignoring the local time.
* @returns {Date}
*/
export const inputStringToIsoDate = (value, utc = false) => {
let date = new Date(value);
if (utc) {
// Forces date to be interpreted as UTC by stripping the timezone
// by formatting to a string with 'Z' and skipping timezone
date = dateformat(date, dateFormats.stripTimezoneFormat);
}
return dateformat(date, 'isoUtcDateTime');
};
/**
* Converts a iso date string to a formatted string for the Time picker component.
*
* @param {String} ISO Formatted date
* @returns {string}
*/
export const isoDateToInputString = (date, utc = false) =>
dateformat(date, dateFormats.inputFormat, utc);

View File

@ -1,85 +0,0 @@
<script>
import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui';
import { isString } from 'lodash';
const isValidItem = (item) =>
isString(item.eventName) && isString(item.title) && isString(item.description);
export default {
components: {
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
},
props: {
actionItems: {
type: Array,
required: true,
validator(value) {
return value.length > 1 && value.every(isValidItem);
},
},
menuClass: {
type: String,
required: false,
default: '',
},
variant: {
type: String,
required: false,
default: 'default',
},
},
data() {
return {
selectedItem: this.actionItems[0],
};
},
computed: {
dropdownToggleText() {
return this.selectedItem.title;
},
},
methods: {
triggerEvent() {
this.$emit(this.selectedItem.eventName);
},
changeSelectedItem(item) {
this.selectedItem = item;
this.$emit('change', item);
},
},
};
</script>
<template>
<gl-dropdown
:menu-class="menuClass"
split
:text="dropdownToggleText"
:variant="variant"
v-bind="$attrs"
@click="triggerEvent"
>
<template v-for="(item, itemIndex) in actionItems">
<gl-dropdown-item
:key="item.eventName"
is-check-item
:is-checked="selectedItem === item"
@click="changeSelectedItem(item)"
>
<strong>{{ item.title }}</strong>
<div>{{ item.description }}</div>
</gl-dropdown-item>
<gl-dropdown-divider
v-if="itemIndex < actionItems.length - 1"
:key="`${item.eventName}-divider`"
/>
</template>
</gl-dropdown>
</template>

View File

@ -20,22 +20,6 @@ module IssuableCollections
set_pagination
return if redirect_out_of_range(@issuables, @total_pages)
if params[:label_name].present? && @project
labels_params = { project_id: @project.id, title: params[:label_name] }
@labels = LabelsFinder.new(current_user, labels_params).execute
end
@users = []
if params[:assignee_id].present?
assignee = User.find_by_id(params[:assignee_id])
@users.push(assignee) if assignee
end
if params[:author_id].present?
author = User.find_by_id(params[:author_id])
@users.push(author) if author
end
end
def set_pagination

View File

@ -113,12 +113,6 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format|
format.html
format.atom { render layout: 'xml' }
format.json do
render json: {
html: view_to_html_string("projects/issues/_issues"),
labels: @labels.as_json(methods: :text_color)
}
end
end
end
@ -281,7 +275,6 @@ class Projects::IssuesController < Projects::ApplicationController
def service_desk
@issues = @issuables
@users.push(Users::Internal.support_bot)
end
protected

View File

@ -104,11 +104,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
respond_to do |format|
format.html
format.atom { render layout: 'xml' }
format.json do
render json: {
html: view_to_html_string("projects/merge_requests/_merge_requests")
}
end
end
end

View File

@ -19,6 +19,8 @@
= s_('AdminSettings|Restricted visibility levels')
%small.form-text.text-gl-muted
= s_('AdminSettings|Prevent non-administrators from using the selected visibility levels for groups, projects and snippets.')
= s_('AdminSettings|The selected level must be different from the selected default group and project visibility.')
= link_to _('Learn more.'), help_page_path('administration/settings/visibility_and_access_controls', anchor: 'restrict-visibility-levels'), target: '_blank', rel: 'noopener noreferrer'
= hidden_field_tag 'application_setting[restricted_visibility_levels][]'
.gl-form-checkbox-group
- restricted_level_checkboxes(f).each do |checkbox|

View File

@ -5,4 +5,4 @@ rollout_issue_url:
milestone: '16.3'
type: development
group: group::acquisition
default_enabled: false
default_enabled: true

View File

@ -1,8 +0,0 @@
---
name: super_sidebar_flyout_menus
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124863
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/417237
milestone: '16.2'
type: development
group: group::foundations
default_enabled: false

View File

@ -0,0 +1,5 @@
migration_job_name: BackfillWorkspacePersonalAccessToken
description: Create personal access token for workspaces without one
feature_category: remote_development
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131516
milestone: 16.4

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
class QueueBackfillWorkspacePersonalAccessToken < Gitlab::Database::Migration[2.1]
MIGRATION = "BackfillWorkspacePersonalAccessToken"
DELAY_INTERVAL = 2.minutes
BATCH_SIZE = 100
SUB_BATCH_SIZE = 10
restrict_gitlab_migration gitlab_schema: :gitlab_main
disable_ddl_transaction!
def up
queue_batched_background_migration(
MIGRATION,
:workspaces,
:id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
delete_batched_background_migration(MIGRATION, :workspaces, :id, [])
end
end

View File

@ -0,0 +1 @@
75402594bdc333a34f7b49db4d5008fddad10f346dd15d65e4552cac20b442fb

View File

@ -132,6 +132,9 @@ To set the default [visibility levels for new projects](../../user/public_access
- **Public** - The project can be accessed without any authentication.
1. Select **Save changes**.
For more details on project visibility, see
[Project visibility](../../user/public_access.md).
## Configure snippet visibility defaults
To set the default visibility levels for new [snippets](../../user/snippets.md):
@ -145,7 +148,7 @@ To set the default visibility levels for new [snippets](../../user/snippets.md):
1. Select **Save changes**.
For more details on snippet visibility, read
[Project visibility](../../user/public_access.md).
[Snippet visibility](../../user/snippets.md).
## Configure group visibility defaults
@ -167,6 +170,9 @@ For more details on group visibility, see
## Restrict visibility levels
> - [Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124649) in GitLab 16.3 to prevent restricting default project and group visibility, [with a flag](../feature_flags.md) named `prevent_visibility_restriction`. Disabled by default.
> - `prevent_visibility_restriction` [enabled](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131203) by default in GitLab 16.4.
When restricting visibility levels, consider how these restrictions interact
with permissions for subgroups and projects that inherit their visibility from
the item you're changing.
@ -191,8 +197,8 @@ To restrict visibility levels for groups, projects, snippets, and selected pages
- Only administrators are able to create private groups, projects, and snippets.
1. Select **Save changes**.
For more details on project visibility, see
[Project visibility](../../user/public_access.md).
NOTE:
You cannot select the restricted default visibility level for new projects and groups.
## Configure enabled Git access protocols

View File

@ -347,10 +347,10 @@ listed in the descriptions of the relevant settings.
| `default_branch_name` | string | no | [Instance-level custom initial branch name](../user/project/repository/branches/default.md#instance-level-custom-initial-branch-name). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/225258) in GitLab 13.2. |
| `default_branch_protection` | integer | no | Determine if developers can push to the default branch. Can take: `0` _(not protected, both users with the Developer role or Maintainer role can push new commits and force push)_, `1` _(partially protected, users with the Developer role or Maintainer role can push new commits, but cannot force push)_ or `2` _(fully protected, users with the Developer or Maintainer role cannot push new commits, but users with the Developer or Maintainer role can; no one can force push)_ as a parameter. Default is `2`. |
| `default_ci_config_path` | string | no | Default CI/CD configuration file and path for new projects (`.gitlab-ci.yml` if not set). |
| `default_group_visibility` | string | no | What visibility level new groups receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. |
| `default_group_visibility` | string | no | What visibility level new groups receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. [Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131203) in GitLab 16.4: cannot be set to any levels in `restricted_visibility_levels`.|
| `default_preferred_language` | string | no | Default preferred language for users who are not logged in. |
| `default_project_creation` | integer | no | Default project creation protection. Can take: `0` _(No one)_, `1` _(Maintainers)_ or `2` _(Developers + Maintainers)_|
| `default_project_visibility` | string | no | What visibility level new projects receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. |
| `default_project_visibility` | string | no | What visibility level new projects receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. [Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131203) in GitLab 16.4: cannot be set to any levels in `restricted_visibility_levels`.|
| `default_projects_limit` | integer | no | Project limit per user. Default is `100000`. |
| `default_snippet_visibility` | string | no | What visibility level new snippets receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. |
| `default_syntax_highlighting_theme` | integer | no | Default syntax highlighting theme for users who are new or not signed in. See [IDs of available themes](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/themes.rb#L16). |
@ -528,7 +528,7 @@ listed in the descriptions of the relevant settings.
| `repository_storages` | array of strings | no | (GitLab 13.0 and earlier) List of names of enabled storage paths, taken from `gitlab.yml`. New projects are created in one of these stores, chosen at random. |
| `require_admin_approval_after_user_signup` | boolean | no | When enabled, any user that signs up for an account using the registration form is placed under a **Pending approval** state and has to be explicitly [approved](../administration/moderate_users.md) by an administrator. |
| `require_two_factor_authentication` | boolean | no | (**If enabled, requires:** `two_factor_grace_period`) Require all users to set up Two-factor authentication. |
| `restricted_visibility_levels` | array of strings | no | Selected levels cannot be used by non-Administrator users for groups, projects or snippets. Can take `private`, `internal` and `public` as a parameter. Default is `null` which means there is no restriction. |
| `restricted_visibility_levels` | array of strings | no | Selected levels cannot be used by non-Administrator users for groups, projects or snippets. Can take `private`, `internal` and `public` as a parameter. Default is `null` which means there is no restriction.[Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131203) in GitLab 16.4: cannot select levels that are set as `default_project_visibility` and `default_group_visibility`. |
| `rsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded RSA key. Default is `0` (no restriction). `-1` disables RSA keys. |
| `session_expire_delay` | integer | no | Session duration in minutes. GitLab restart is required to apply changes. |
| `security_policy_global_group_approvers_enabled` | boolean | no | Whether to look up scan result policy approval groups globally or within project hierarchies. |

View File

@ -167,6 +167,8 @@ On self-managed GitLab, by default this feature is available. On GitLab.com this
You can use Cisco Duo as an OTP provider in GitLab.
DUO® is a registered trademark of Cisco Systems, Inc., and/or its affiliates in the United States and certain other countries.
#### Prerequisites
To use Cisco Duo as an OTP provider:

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# No op on ce
class BackfillWorkspacePersonalAccessToken < BatchedMigrationJob
feature_category :remote_development
def perform; end
end
end
end
Gitlab::BackgroundMigration::BackfillWorkspacePersonalAccessToken.prepend_mod_with('Gitlab::BackgroundMigration::BackfillWorkspacePersonalAccessToken') # rubocop:disable Layout/LineLength

View File

@ -80,7 +80,6 @@ module Gitlab
push_frontend_feature_flag(:remove_monitor_metrics)
push_frontend_feature_flag(:gitlab_duo, current_user)
push_frontend_feature_flag(:custom_emoji)
push_frontend_feature_flag(:super_sidebar_flyout_menus, current_user)
end
# Exposes the state of a feature flag to the frontend code.

View File

@ -1133,9 +1133,6 @@ msgstr ""
msgid "%{startDate} No due date"
msgstr ""
msgid "%{start} to %{end}"
msgstr ""
msgid "%{statusStart}Dismissed%{statusEnd}"
msgstr ""
@ -1686,12 +1683,6 @@ msgstr ""
msgid "2FADevice|Registered On"
msgstr ""
msgid "3 hours"
msgstr ""
msgid "30 minutes"
msgstr ""
msgid "30+ contributions"
msgstr ""
@ -1719,9 +1710,6 @@ msgstr ""
msgid "409|There was a conflict with your request."
msgstr ""
msgid "8 hours"
msgstr ""
msgid ":%{startLine} to %{endLine}"
msgstr ""
@ -3696,6 +3684,9 @@ msgstr ""
msgid "AdminSettings|The maximum number of included files per pipeline."
msgstr ""
msgid "AdminSettings|The selected level must be different from the selected default group and project visibility."
msgstr ""
msgid "AdminSettings|The template for the required pipeline configuration can be one of the GitLab-provided templates, or a custom template added to an instance template repository. %{link_start}How do I create an instance template repository?%{link_end}"
msgstr ""
@ -14416,9 +14407,6 @@ msgstr ""
msgid "Custom range"
msgstr ""
msgid "Custom range (UTC)"
msgstr ""
msgid "Customer contacts"
msgstr ""
@ -20655,9 +20643,6 @@ msgstr ""
msgid "ForksDivergence|View merge request"
msgstr ""
msgid "Format: %{dateFormat}"
msgstr ""
msgid "Framework successfully deleted"
msgstr ""
@ -25413,9 +25398,6 @@ msgstr ""
msgid "Invalid date format. Please use UTC format as YYYY-MM-DD"
msgstr ""
msgid "Invalid date range"
msgstr ""
msgid "Invalid dates set"
msgstr ""
@ -38595,9 +38577,6 @@ msgstr ""
msgid "Quick help"
msgstr ""
msgid "Quick range"
msgstr ""
msgid "README"
msgstr ""

View File

@ -1756,7 +1756,7 @@ RSpec.describe Projects::IssuesController, :request_store, feature_category: :te
it 'allows an assignee to be specified by id' do
get_service_desk(assignee_id: other_user.id)
expect(assigns(:users)).to contain_exactly(other_user, support_bot)
expect(assigns(:issues)).to contain_exactly(service_desk_issue_2)
end
end

View File

@ -1,382 +0,0 @@
import _ from 'lodash';
import {
getRangeType,
convertToFixedRange,
isEqualTimeRanges,
findTimeRange,
timeRangeToParams,
timeRangeFromParams,
} from '~/lib/utils/datetime_range';
const MOCK_NOW = Date.UTC(2020, 0, 23, 20);
const MOCK_NOW_ISO_STRING = new Date(MOCK_NOW).toISOString();
const mockFixedRange = {
label: 'January 2020',
start: '2020-01-01T00:00:00.000Z',
end: '2020-01-31T23:59:00.000Z',
};
const mockAnchoredRange = {
label: 'First two minutes of 2020',
anchor: '2020-01-01T00:00:00.000Z',
direction: 'after',
duration: {
seconds: 60 * 2,
},
};
const mockRollingRange = {
label: 'Next 2 minutes',
direction: 'after',
duration: {
seconds: 60 * 2,
},
};
const mockOpenRange = {
label: '2020 so far',
anchor: '2020-01-01T00:00:00.000Z',
direction: 'after',
};
describe('Date time range utils', () => {
describe('getRangeType', () => {
it('infers correctly the range type from the input object', () => {
const rangeTypes = {
fixed: [{ start: MOCK_NOW_ISO_STRING, end: MOCK_NOW_ISO_STRING }],
anchored: [{ anchor: MOCK_NOW_ISO_STRING, duration: { seconds: 0 } }],
rolling: [{ duration: { seconds: 0 } }],
open: [{ anchor: MOCK_NOW_ISO_STRING }],
invalid: [
{},
{ start: MOCK_NOW_ISO_STRING },
{ end: MOCK_NOW_ISO_STRING },
{ start: 'NOT_A_DATE', end: 'NOT_A_DATE' },
{ duration: { seconds: 'NOT_A_NUMBER' } },
{ duration: { seconds: Infinity } },
{ duration: { minutes: 20 } },
{ anchor: MOCK_NOW_ISO_STRING, duration: { seconds: 'NOT_A_NUMBER' } },
{ anchor: MOCK_NOW_ISO_STRING, duration: { seconds: Infinity } },
{ junk: 'exists' },
],
};
Object.entries(rangeTypes).forEach(([type, examples]) => {
examples.forEach((example) => expect(getRangeType(example)).toEqual(type));
});
});
});
describe('convertToFixedRange', () => {
beforeEach(() => {
jest.spyOn(Date, 'now').mockImplementation(() => MOCK_NOW);
});
afterEach(() => {
Date.now.mockRestore();
});
describe('When a fixed range is input', () => {
it('converts a fixed range to an equal fixed range', () => {
expect(convertToFixedRange(mockFixedRange)).toEqual({
start: mockFixedRange.start,
end: mockFixedRange.end,
});
});
it('throws an error when fixed range does not contain an end time', () => {
const aFixedRangeMissingEnd = _.omit(mockFixedRange, 'end');
expect(() => convertToFixedRange(aFixedRangeMissingEnd)).toThrow();
});
it('throws an error when fixed range does not contain a start time', () => {
const aFixedRangeMissingStart = _.omit(mockFixedRange, 'start');
expect(() => convertToFixedRange(aFixedRangeMissingStart)).toThrow();
});
it('throws an error when the dates cannot be parsed', () => {
const wrongStart = { ...mockFixedRange, start: 'I_CANNOT_BE_PARSED' };
const wrongEnd = { ...mockFixedRange, end: 'I_CANNOT_BE_PARSED' };
expect(() => convertToFixedRange(wrongStart)).toThrow();
expect(() => convertToFixedRange(wrongEnd)).toThrow();
});
});
describe('When an anchored range is input', () => {
it('converts to a fixed range', () => {
expect(convertToFixedRange(mockAnchoredRange)).toEqual({
start: '2020-01-01T00:00:00.000Z',
end: '2020-01-01T00:02:00.000Z',
});
});
it('converts to a fixed range with a `before` direction', () => {
expect(convertToFixedRange({ ...mockAnchoredRange, direction: 'before' })).toEqual({
start: '2019-12-31T23:58:00.000Z',
end: '2020-01-01T00:00:00.000Z',
});
});
it('converts to a fixed range without an explicit direction, defaulting to `before`', () => {
const defaultDirectionRange = _.omit(mockAnchoredRange, 'direction');
expect(convertToFixedRange(defaultDirectionRange)).toEqual({
start: '2019-12-31T23:58:00.000Z',
end: '2020-01-01T00:00:00.000Z',
});
});
it('throws an error when the anchor cannot be parsed', () => {
const wrongAnchor = { ...mockAnchoredRange, anchor: 'I_CANNOT_BE_PARSED' };
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
});
});
describe('when a rolling range is input', () => {
it('converts to a fixed range', () => {
expect(convertToFixedRange(mockRollingRange)).toEqual({
start: '2020-01-23T20:00:00.000Z',
end: '2020-01-23T20:02:00.000Z',
});
});
it('converts to a fixed range with an implicit `before` direction', () => {
const noDirection = _.omit(mockRollingRange, 'direction');
expect(convertToFixedRange(noDirection)).toEqual({
start: '2020-01-23T19:58:00.000Z',
end: '2020-01-23T20:00:00.000Z',
});
});
it('throws an error when the duration is not in the right format', () => {
const wrongDuration = { ...mockRollingRange, duration: { minutes: 20 } };
expect(() => convertToFixedRange(wrongDuration)).toThrow();
});
it('throws an error when the anchor is not valid', () => {
const wrongAnchor = { ...mockRollingRange, anchor: 'CAN_T_PARSE_THIS' };
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
});
});
describe('when an open range is input', () => {
it('converts to a fixed range with an `after` direction', () => {
expect(convertToFixedRange(mockOpenRange)).toEqual({
start: '2020-01-01T00:00:00.000Z',
end: '2020-01-23T20:00:00.000Z',
});
});
it('converts to a fixed range with the explicit `before` direction', () => {
const beforeOpenRange = { ...mockOpenRange, direction: 'before' };
expect(convertToFixedRange(beforeOpenRange)).toEqual({
start: '1970-01-01T00:00:00.000Z',
end: '2020-01-01T00:00:00.000Z',
});
});
it('converts to a fixed range with the implicit `before` direction', () => {
const noDirectionOpenRange = _.omit(mockOpenRange, 'direction');
expect(convertToFixedRange(noDirectionOpenRange)).toEqual({
start: '1970-01-01T00:00:00.000Z',
end: '2020-01-01T00:00:00.000Z',
});
});
it('throws an error when the anchor cannot be parsed', () => {
const wrongAnchor = { ...mockOpenRange, anchor: 'CAN_T_PARSE_THIS' };
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
});
});
});
describe('isEqualTimeRanges', () => {
it('equal only compares relevant properies', () => {
expect(
isEqualTimeRanges(
{
...mockFixedRange,
label: 'A label',
default: true,
},
{
...mockFixedRange,
label: 'Another label',
default: false,
anotherKey: 'anotherValue',
},
),
).toBe(true);
expect(
isEqualTimeRanges(
{
...mockAnchoredRange,
label: 'A label',
default: true,
},
{
...mockAnchoredRange,
anotherKey: 'anotherValue',
},
),
).toBe(true);
});
});
describe('findTimeRange', () => {
const timeRanges = [
{
label: 'Before 2020',
anchor: '2020-01-01T00:00:00.000Z',
},
{
label: 'Last 30 minutes',
duration: { seconds: 60 * 30 },
},
{
label: 'In 2019',
start: '2019-01-01T00:00:00.000Z',
end: '2019-12-31T12:59:59.999Z',
},
{
label: 'Next 2 minutes',
direction: 'after',
duration: {
seconds: 60 * 2,
},
},
];
it('finds a time range', () => {
const tr0 = {
anchor: '2020-01-01T00:00:00.000Z',
};
expect(findTimeRange(tr0, timeRanges)).toBe(timeRanges[0]);
const tr1 = {
duration: { seconds: 60 * 30 },
};
expect(findTimeRange(tr1, timeRanges)).toBe(timeRanges[1]);
const tr1Direction = {
direction: 'before',
duration: {
seconds: 60 * 30,
},
};
expect(findTimeRange(tr1Direction, timeRanges)).toBe(timeRanges[1]);
const tr2 = {
someOtherLabel: 'Added arbitrarily',
start: '2019-01-01T00:00:00.000Z',
end: '2019-12-31T12:59:59.999Z',
};
expect(findTimeRange(tr2, timeRanges)).toBe(timeRanges[2]);
const tr3 = {
direction: 'after',
duration: {
seconds: 60 * 2,
},
};
expect(findTimeRange(tr3, timeRanges)).toBe(timeRanges[3]);
});
it('doesnot finds a missing time range', () => {
const nonExistant = {
direction: 'before',
duration: {
seconds: 200,
},
};
expect(findTimeRange(nonExistant, timeRanges)).toBeUndefined();
});
});
describe('conversion to/from params', () => {
const mockFixedParams = {
start: '2020-01-01T00:00:00.000Z',
end: '2020-01-31T23:59:00.000Z',
};
const mockAnchoredParams = {
anchor: '2020-01-01T00:00:00.000Z',
direction: 'after',
duration_seconds: '120',
};
const mockRollingParams = {
direction: 'after',
duration_seconds: '120',
};
describe('timeRangeToParams', () => {
it('converts fixed ranges to params', () => {
expect(timeRangeToParams(mockFixedRange)).toEqual(mockFixedParams);
});
it('converts anchored ranges to params', () => {
expect(timeRangeToParams(mockAnchoredRange)).toEqual(mockAnchoredParams);
});
it('converts rolling ranges to params', () => {
expect(timeRangeToParams(mockRollingRange)).toEqual(mockRollingParams);
});
});
describe('timeRangeFromParams', () => {
it('converts fixed ranges from params', () => {
const params = { ...mockFixedParams, other_param: 'other_value' };
const expectedRange = _.omit(mockFixedRange, 'label');
expect(timeRangeFromParams(params)).toEqual(expectedRange);
});
it('converts anchored ranges to params', () => {
const expectedRange = _.omit(mockRollingRange, 'label');
expect(timeRangeFromParams(mockRollingParams)).toEqual(expectedRange);
});
it('converts rolling ranges from params', () => {
const params = { ...mockRollingParams, other_param: 'other_value' };
const expectedRange = _.omit(mockRollingRange, 'label');
expect(timeRangeFromParams(params)).toEqual(expectedRange);
});
it('converts rolling ranges from params with a default direction', () => {
const params = {
...mockRollingParams,
direction: 'before',
other_param: 'other_value',
};
const expectedRange = _.omit(mockRollingRange, 'label', 'direction');
expect(timeRangeFromParams(params)).toEqual(expectedRange);
});
it('converts to null when for no relevant params', () => {
const range = {
useless_param_1: 'value1',
useless_param_2: 'value2',
};
expect(timeRangeFromParams(range)).toBe(null);
});
});
});
});

View File

@ -16,13 +16,8 @@ const menuItems = [
describe('Sidebar Menu', () => {
let wrapper;
let flyoutFlag = false;
const createWrapper = (extraProps = {}) => {
wrapper = shallowMountExtended(SidebarMenu, {
provide: {
glFeatures: { superSidebarFlyoutMenus: flyoutFlag },
},
propsData: {
items: sidebarData.current_menu_items,
isLoggedIn: sidebarData.is_logged_in,
@ -125,8 +120,11 @@ describe('Sidebar Menu', () => {
});
describe('flyout menus', () => {
describe('when feature is disabled', () => {
describe('when screen width is smaller than "md" breakpoint', () => {
beforeEach(() => {
jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => {
return 767;
});
createWrapper({
items: menuItems,
});
@ -140,45 +138,21 @@ describe('Sidebar Menu', () => {
});
});
describe('when feature is enabled', () => {
describe('when screen width is equal or larger than "md" breakpoint', () => {
beforeEach(() => {
flyoutFlag = true;
});
describe('when screen width is smaller than "md" breakpoint', () => {
beforeEach(() => {
jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => {
return 767;
});
createWrapper({
items: menuItems,
});
jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => {
return 768;
});
it('does not add flyout menus to sections', () => {
expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([
false,
false,
]);
createWrapper({
items: menuItems,
});
});
describe('when screen width is equal or larger than "md" breakpoint', () => {
beforeEach(() => {
jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => {
return 768;
});
createWrapper({
items: menuItems,
});
});
it('adds flyout menus to sections', () => {
expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([
true,
true,
]);
});
it('adds flyout menus to sections', () => {
expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([
true,
true,
]);
});
});
});

View File

@ -1,54 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SplitButton renders actionItems 1`] = `
<gl-dropdown-stub
category="primary"
clearalltext="Clear all"
clearalltextclass="gl-px-5"
headertext=""
hideheaderborder="true"
highlighteditemstitle="Selected"
highlighteditemstitleclass="gl-px-5"
menu-class=""
size="medium"
split="true"
splithref=""
text="professor"
variant="default"
>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischecked="true"
ischeckitem="true"
secondarytext=""
>
<strong>
professor
</strong>
<div>
very symphonic
</div>
</gl-dropdown-item-stub>
<gl-dropdown-divider-stub />
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<strong>
captain
</strong>
<div>
warp drive
</div>
</gl-dropdown-item-stub>
</gl-dropdown-stub>
`;

View File

@ -1,62 +0,0 @@
import { mount } from '@vue/test-utils';
import DateTimePickerInput from '~/vue_shared/components/date_time_picker/date_time_picker_input.vue';
const inputLabel = 'This is a label';
const inputValue = 'something';
describe('DateTimePickerInput', () => {
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = mount(DateTimePickerInput, {
propsData: {
state: null,
value: '',
label: '',
...propsData,
},
});
};
it('renders label above the input', () => {
createComponent({
label: inputLabel,
});
expect(wrapper.find('.gl-form-group label').text()).toBe(inputLabel);
});
it('renders the same `ID` for input and `for` for label', () => {
createComponent({ label: inputLabel });
expect(wrapper.find('.gl-form-group label').attributes('for')).toBe(
wrapper.find('input').attributes('id'),
);
});
it('renders valid input in gray color instead of green', () => {
createComponent({
state: true,
});
expect(wrapper.find('input').classes('is-valid')).toBe(false);
});
it('renders invalid input in red color', () => {
createComponent({
state: false,
});
expect(wrapper.find('input').classes('is-invalid')).toBe(true);
});
it('input event is emitted when focus is lost', () => {
createComponent();
const input = wrapper.find('input');
input.setValue(inputValue);
input.trigger('blur');
expect(wrapper.emitted('input')[0][0]).toEqual(inputValue);
});
});

View File

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

View File

@ -1,326 +0,0 @@
import { mount } from '@vue/test-utils';
import timezoneMock from 'timezone-mock';
import { nextTick } from 'vue';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import {
defaultTimeRanges,
defaultTimeRange,
} from '~/vue_shared/components/date_time_picker/date_time_picker_lib';
const optionsCount = defaultTimeRanges.length;
describe('DateTimePicker', () => {
let wrapper;
const dropdownToggle = () => wrapper.find('.dropdown-toggle');
const dropdownMenu = () => wrapper.find('.dropdown-menu');
const cancelButton = () => wrapper.find('[data-testid="cancelButton"]');
const applyButtonElement = () => wrapper.find('button.btn-confirm').element;
const findQuickRangeItems = () => wrapper.findAll('.dropdown-item');
const createComponent = (props) => {
wrapper = mount(DateTimePicker, {
propsData: {
...props,
},
});
};
it('renders dropdown toggle button with selected text', async () => {
createComponent();
await nextTick();
expect(dropdownToggle().text()).toBe(defaultTimeRange.label);
});
it('renders dropdown toggle button with selected text and utc label', async () => {
createComponent({ utc: true });
await nextTick();
expect(dropdownToggle().text()).toContain(defaultTimeRange.label);
expect(dropdownToggle().text()).toContain('UTC');
});
it('renders dropdown with 2 custom time range inputs', async () => {
createComponent();
await nextTick();
expect(wrapper.findAll('input').length).toBe(2);
});
describe('renders label with h/m/s truncated if possible', () => {
[
{
start: '2019-10-10T00:00:00.000Z',
end: '2019-10-10T00:00:00.000Z',
label: '2019-10-10 to 2019-10-10',
},
{
start: '2019-10-10T00:00:00.000Z',
end: '2019-10-14T00:10:00.000Z',
label: '2019-10-10 to 2019-10-14 00:10:00',
},
{
start: '2019-10-10T00:00:00.000Z',
end: '2019-10-10T00:00:01.000Z',
label: '2019-10-10 to 2019-10-10 00:00:01',
},
{
start: '2019-10-10T00:00:01.000Z',
end: '2019-10-10T00:00:01.000Z',
label: '2019-10-10 00:00:01 to 2019-10-10 00:00:01',
},
{
start: '2019-10-10T00:00:01.000Z',
end: '2019-10-10T00:00:01.000Z',
utc: true,
label: '2019-10-10 00:00:01 to 2019-10-10 00:00:01 UTC',
},
].forEach(({ start, end, utc, label }) => {
it(`for start ${start}, end ${end}, and utc ${utc}, label is ${label}`, async () => {
createComponent({
value: { start, end },
utc,
});
await nextTick();
expect(dropdownToggle().text()).toBe(label);
});
});
});
it(`renders dropdown with ${optionsCount} (default) items in quick range`, async () => {
createComponent();
dropdownToggle().trigger('click');
await nextTick();
expect(findQuickRangeItems().length).toBe(optionsCount);
});
it('renders dropdown with a default quick range item selected', async () => {
createComponent();
dropdownToggle().trigger('click');
await nextTick();
expect(wrapper.find('.dropdown-item.active').exists()).toBe(true);
expect(wrapper.find('.dropdown-item.active').text()).toBe(defaultTimeRange.label);
});
it('renders a disabled apply button on wrong input', () => {
createComponent({
start: 'invalid-input-date',
});
expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
});
describe('user input', () => {
const fillInputAndBlur = async (input, val) => {
wrapper.find(input).setValue(val);
await nextTick();
wrapper.find(input).trigger('blur');
await nextTick();
};
beforeEach(async () => {
createComponent();
await nextTick();
});
it('displays inline error message if custom time range inputs are invalid', async () => {
await fillInputAndBlur('#custom-time-from', '2019-10-01abc');
await fillInputAndBlur('#custom-time-to', '2019-10-10abc');
expect(wrapper.findAll('.invalid-feedback').length).toBe(2);
});
it('keeps apply button disabled with invalid custom time range inputs', async () => {
await fillInputAndBlur('#custom-time-from', '2019-10-01abc');
await fillInputAndBlur('#custom-time-to', '2019-09-19');
expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
});
it('enables apply button with valid custom time range inputs', async () => {
await fillInputAndBlur('#custom-time-from', '2019-10-01');
await fillInputAndBlur('#custom-time-to', '2019-10-19');
expect(applyButtonElement().getAttribute('disabled')).toBeNull();
});
describe('when "apply" is clicked', () => {
it('emits iso dates', async () => {
await fillInputAndBlur('#custom-time-from', '2019-10-01 00:00:00');
await fillInputAndBlur('#custom-time-to', '2019-10-19 00:00:00');
applyButtonElement().click();
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0]).toEqual([
{
end: '2019-10-19T00:00:00Z',
start: '2019-10-01T00:00:00Z',
},
]);
});
it('emits iso dates, for dates without time of day', async () => {
await fillInputAndBlur('#custom-time-from', '2019-10-01');
await fillInputAndBlur('#custom-time-to', '2019-10-19');
applyButtonElement().click();
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0]).toEqual([
{
end: '2019-10-19T00:00:00Z',
start: '2019-10-01T00:00:00Z',
},
]);
});
describe('when timezone is different', () => {
beforeAll(() => {
timezoneMock.register('US/Pacific');
});
afterAll(() => {
timezoneMock.unregister();
});
it('emits iso dates', async () => {
await fillInputAndBlur('#custom-time-from', '2019-10-01 00:00:00');
await fillInputAndBlur('#custom-time-to', '2019-10-19 12:00:00');
applyButtonElement().click();
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0]).toEqual([
{
start: '2019-10-01T07:00:00Z',
end: '2019-10-19T19:00:00Z',
},
]);
});
it('emits iso dates with utc format', async () => {
wrapper.setProps({ utc: true });
await nextTick();
await fillInputAndBlur('#custom-time-from', '2019-10-01 00:00:00');
await fillInputAndBlur('#custom-time-to', '2019-10-19 12:00:00');
applyButtonElement().click();
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0]).toEqual([
{
start: '2019-10-01T00:00:00Z',
end: '2019-10-19T12:00:00Z',
},
]);
});
});
});
it('unchecks quick range when text is input is clicked', async () => {
const findActiveItems = () =>
findQuickRangeItems().filter((w) => w.classes().includes('active'));
expect(findActiveItems().length).toBe(1);
await fillInputAndBlur('#custom-time-from', '2019-10-01');
expect(findActiveItems().length).toBe(0);
});
it('emits dates in an object when a is clicked', () => {
findQuickRangeItems()
.at(3) // any item
.trigger('click');
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0][0]).toMatchObject({
duration: {
seconds: expect.any(Number),
},
});
});
it('hides the popover with cancel button', async () => {
dropdownToggle().trigger('click');
await nextTick();
cancelButton().trigger('click');
await nextTick();
expect(dropdownMenu().classes('show')).toBe(false);
});
});
describe('when using non-default time windows', () => {
const MOCK_NOW = Date.UTC(2020, 0, 23, 20);
const otherTimeRanges = [
{
label: '1 minute',
duration: { seconds: 60 },
},
{
label: '2 minutes',
duration: { seconds: 60 * 2 },
default: true,
},
{
label: '5 minutes',
duration: { seconds: 60 * 5 },
},
];
beforeEach(() => {
jest.spyOn(Date, 'now').mockImplementation(() => MOCK_NOW);
});
it('renders dropdown with a label in the quick range', async () => {
createComponent({
value: {
duration: { seconds: 60 * 5 },
},
options: otherTimeRanges,
});
dropdownToggle().trigger('click');
await nextTick();
expect(dropdownToggle().text()).toBe('5 minutes');
});
it('renders dropdown with a label in the quick range and utc label', async () => {
createComponent({
value: {
duration: { seconds: 60 * 5 },
},
utc: true,
options: otherTimeRanges,
});
dropdownToggle().trigger('click');
await nextTick();
expect(dropdownToggle().text()).toBe('5 minutes UTC');
});
it('renders dropdown with quick range items', async () => {
createComponent({
value: {
duration: { seconds: 60 * 2 },
},
options: otherTimeRanges,
});
dropdownToggle().trigger('click');
await nextTick();
const items = findQuickRangeItems();
expect(items.length).toBe(Object.keys(otherTimeRanges).length);
expect(items.at(0).text()).toBe('1 minute');
expect(items.at(0).classes()).not.toContain('active');
expect(items.at(1).text()).toBe('2 minutes');
expect(items.at(1).classes()).toContain('active');
expect(items.at(2).text()).toBe('5 minutes');
expect(items.at(2).classes()).not.toContain('active');
});
it('renders dropdown with a label not in the quick range', async () => {
createComponent({
value: {
duration: { seconds: 60 * 4 },
},
});
dropdownToggle().trigger('click');
await nextTick();
expect(dropdownToggle().text()).toBe('2020-01-23 19:56:00 to 2020-01-23 20:00:00');
});
});
});

View File

@ -1,117 +0,0 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { assertProps } from 'helpers/assert_props';
import SplitButton from '~/vue_shared/components/split_button.vue';
const mockActionItems = [
{
eventName: 'concert',
title: 'professor',
description: 'very symphonic',
},
{
eventName: 'apocalypse',
title: 'captain',
description: 'warp drive',
},
];
describe('SplitButton', () => {
let wrapper;
const createComponent = (propsData) => {
wrapper = shallowMount(SplitButton, {
propsData,
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItem = (index = 0) =>
findDropdown().findAllComponents(GlDropdownItem).at(index);
const selectItem = async (index) => {
findDropdownItem(index).vm.$emit('click');
await nextTick();
};
const clickToggleButton = async () => {
findDropdown().vm.$emit('click');
await nextTick();
};
it('fails for empty actionItems', () => {
const actionItems = [];
expect(() => assertProps(SplitButton, { actionItems })).toThrow();
});
it('fails for single actionItems', () => {
const actionItems = [mockActionItems[0]];
expect(() => assertProps(SplitButton, { actionItems })).toThrow();
});
it('renders actionItems', () => {
createComponent({ actionItems: mockActionItems });
expect(wrapper.element).toMatchSnapshot();
});
describe('toggle button text', () => {
beforeEach(() => {
createComponent({ actionItems: mockActionItems });
});
it('defaults to first actionItems title', () => {
expect(findDropdown().props().text).toBe(mockActionItems[0].title);
});
it('changes to selected actionItems title', () =>
selectItem(1).then(() => {
expect(findDropdown().props().text).toBe(mockActionItems[1].title);
}));
});
describe('emitted event', () => {
let eventHandler;
let changeEventHandler;
beforeEach(() => {
createComponent({ actionItems: mockActionItems });
});
const addEventHandler = ({ eventName }) => {
eventHandler = jest.fn();
wrapper.vm.$once(eventName, () => eventHandler());
};
const addChangeEventHandler = () => {
changeEventHandler = jest.fn();
wrapper.vm.$once('change', (item) => changeEventHandler(item));
};
it('defaults to first actionItems event', () => {
addEventHandler(mockActionItems[0]);
return clickToggleButton().then(() => {
expect(eventHandler).toHaveBeenCalled();
});
});
it('changes to selected actionItems event', () =>
selectItem(1)
.then(() => addEventHandler(mockActionItems[1]))
.then(clickToggleButton)
.then(() => {
expect(eventHandler).toHaveBeenCalled();
}));
it('change to selected actionItem emits change event', () => {
addChangeEventHandler();
return selectItem(1).then(() => {
expect(changeEventHandler).toHaveBeenCalledWith(mockActionItems[1]);
});
});
});
});