Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
7a15fb07cf
commit
d30dfdfd05
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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|
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ rollout_issue_url:
|
|||
milestone: '16.3'
|
||||
type: development
|
||||
group: group::acquisition
|
||||
default_enabled: false
|
||||
default_enabled: true
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
75402594bdc333a34f7b49db4d5008fddad10f346dd15d65e4552cac20b442fb
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue