Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
6b9d3a4e83
commit
27d314277b
27 changed files with 668 additions and 233 deletions
223
app/assets/javascripts/lib/utils/datetime_range.js
Normal file
223
app/assets/javascripts/lib/utils/datetime_range.js
Normal file
|
@ -0,0 +1,223 @@
|
||||||
|
import dateformat from 'dateformat';
|
||||||
|
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/i18n/no-non-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));
|
||||||
|
|
||||||
|
const isValidDateString = dateString => {
|
||||||
|
if (typeof dateString !== 'string' || !dateString.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// dateformat throws error that can be caught.
|
||||||
|
// This is better than using `new Date()`
|
||||||
|
dateformat(dateString, 'isoUtcDateTime');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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/i18n/no-non-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) ? 'fixed' : 'invalid';
|
||||||
|
}
|
||||||
|
if (anchor && duration) {
|
||||||
|
return isValidDateString(anchor) && isValidDuration(duration) ? 'anchored' : 'invalid';
|
||||||
|
}
|
||||||
|
if (duration && !anchor) {
|
||||||
|
return isValidDuration(duration) ? 'rolling' : 'invalid';
|
||||||
|
}
|
||||||
|
if (anchor && !duration) {
|
||||||
|
return isValidDateString(anchor) ? 'open' : 'invalid';
|
||||||
|
}
|
||||||
|
return '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);
|
|
@ -1,6 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
|
import { GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
|
||||||
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
|
import ciHeader from '~/vue_shared/components/header_ci_component.vue';
|
||||||
|
import LoadingButton from '~/vue_shared/components/loading_button.vue';
|
||||||
import eventHub from '../event_hub';
|
import eventHub from '../event_hub';
|
||||||
import { __ } from '~/locale';
|
import { __ } from '~/locale';
|
||||||
|
|
||||||
|
@ -12,6 +13,10 @@ export default {
|
||||||
ciHeader,
|
ciHeader,
|
||||||
GlLoadingIcon,
|
GlLoadingIcon,
|
||||||
GlModal,
|
GlModal,
|
||||||
|
LoadingButton,
|
||||||
|
},
|
||||||
|
directives: {
|
||||||
|
GlModal: GlModalDirective,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
pipeline: {
|
pipeline: {
|
||||||
|
@ -25,7 +30,9 @@ export default {
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
actions: this.getActions(),
|
isCanceling: false,
|
||||||
|
isRetrying: false,
|
||||||
|
isDeleting: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -43,67 +50,18 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
|
||||||
pipeline() {
|
|
||||||
this.actions = this.getActions();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
onActionClicked(action) {
|
cancelPipeline() {
|
||||||
if (action.modal) {
|
this.isCanceling = true;
|
||||||
this.$root.$emit('bv::show::modal', action.modal);
|
eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
|
||||||
} else {
|
|
||||||
this.postAction(action);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
postAction(action) {
|
retryPipeline() {
|
||||||
const index = this.actions.indexOf(action);
|
this.isRetrying = true;
|
||||||
|
eventHub.$emit('headerPostAction', this.pipeline.retry_path);
|
||||||
this.$set(this.actions[index], 'isLoading', true);
|
|
||||||
|
|
||||||
eventHub.$emit('headerPostAction', action);
|
|
||||||
},
|
},
|
||||||
deletePipeline() {
|
deletePipeline() {
|
||||||
const index = this.actions.findIndex(action => action.modal === DELETE_MODAL_ID);
|
this.isDeleting = true;
|
||||||
|
eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
|
||||||
this.$set(this.actions[index], 'isLoading', true);
|
|
||||||
|
|
||||||
eventHub.$emit('headerDeleteAction', this.actions[index]);
|
|
||||||
},
|
|
||||||
|
|
||||||
getActions() {
|
|
||||||
const actions = [];
|
|
||||||
|
|
||||||
if (this.pipeline.retry_path) {
|
|
||||||
actions.push({
|
|
||||||
label: __('Retry'),
|
|
||||||
path: this.pipeline.retry_path,
|
|
||||||
cssClass: 'js-retry-button btn btn-inverted-secondary',
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pipeline.cancel_path) {
|
|
||||||
actions.push({
|
|
||||||
label: __('Cancel running'),
|
|
||||||
path: this.pipeline.cancel_path,
|
|
||||||
cssClass: 'js-btn-cancel-pipeline btn btn-danger',
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pipeline.delete_path) {
|
|
||||||
actions.push({
|
|
||||||
label: __('Delete'),
|
|
||||||
path: this.pipeline.delete_path,
|
|
||||||
modal: DELETE_MODAL_ID,
|
|
||||||
cssClass: 'js-btn-delete-pipeline btn btn-danger btn-inverted',
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
DELETE_MODAL_ID,
|
DELETE_MODAL_ID,
|
||||||
|
@ -117,10 +75,38 @@ export default {
|
||||||
:item-id="pipeline.id"
|
:item-id="pipeline.id"
|
||||||
:time="pipeline.created_at"
|
:time="pipeline.created_at"
|
||||||
:user="pipeline.user"
|
:user="pipeline.user"
|
||||||
:actions="actions"
|
|
||||||
item-name="Pipeline"
|
item-name="Pipeline"
|
||||||
@actionClicked="onActionClicked"
|
>
|
||||||
/>
|
<loading-button
|
||||||
|
v-if="pipeline.retry_path"
|
||||||
|
:loading="isRetrying"
|
||||||
|
:disabled="isRetrying"
|
||||||
|
class="js-retry-button btn btn-inverted-secondary"
|
||||||
|
container-class="d-inline"
|
||||||
|
:label="__('Retry')"
|
||||||
|
@click="retryPipeline()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<loading-button
|
||||||
|
v-if="pipeline.cancel_path"
|
||||||
|
:loading="isCanceling"
|
||||||
|
:disabled="isCanceling"
|
||||||
|
class="js-btn-cancel-pipeline btn btn-danger"
|
||||||
|
container-class="d-inline"
|
||||||
|
:label="__('Cancel running')"
|
||||||
|
@click="cancelPipeline()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<loading-button
|
||||||
|
v-if="pipeline.delete_path"
|
||||||
|
v-gl-modal="$options.DELETE_MODAL_ID"
|
||||||
|
:loading="isDeleting"
|
||||||
|
:disabled="isDeleting"
|
||||||
|
class="js-btn-delete-pipeline btn btn-danger btn-inverted"
|
||||||
|
container-class="d-inline"
|
||||||
|
:label="__('Delete')"
|
||||||
|
/>
|
||||||
|
</ci-header>
|
||||||
|
|
||||||
<gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default append-bottom-default" />
|
<gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default append-bottom-default" />
|
||||||
|
|
||||||
|
|
|
@ -70,16 +70,16 @@ export default () => {
|
||||||
eventHub.$off('headerDeleteAction', this.deleteAction);
|
eventHub.$off('headerDeleteAction', this.deleteAction);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
postAction(action) {
|
postAction(path) {
|
||||||
this.mediator.service
|
this.mediator.service
|
||||||
.postAction(action.path)
|
.postAction(path)
|
||||||
.then(() => this.mediator.refreshPipeline())
|
.then(() => this.mediator.refreshPipeline())
|
||||||
.catch(() => Flash(__('An error occurred while making the request.')));
|
.catch(() => Flash(__('An error occurred while making the request.')));
|
||||||
},
|
},
|
||||||
deleteAction(action) {
|
deleteAction(path) {
|
||||||
this.mediator.stopPipelinePoll();
|
this.mediator.stopPipelinePoll();
|
||||||
this.mediator.service
|
this.mediator.service
|
||||||
.deleteAction(action.path)
|
.deleteAction(path)
|
||||||
.then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success')))
|
.then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success')))
|
||||||
.catch(() => Flash(__('An error occurred while deleting the pipeline.')));
|
.catch(() => Flash(__('An error occurred while deleting the pipeline.')));
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { __, sprintf } from '~/locale';
|
||||||
import CiIconBadge from './ci_badge_link.vue';
|
import CiIconBadge from './ci_badge_link.vue';
|
||||||
import TimeagoTooltip from './time_ago_tooltip.vue';
|
import TimeagoTooltip from './time_ago_tooltip.vue';
|
||||||
import UserAvatarImage from './user_avatar/user_avatar_image.vue';
|
import UserAvatarImage from './user_avatar/user_avatar_image.vue';
|
||||||
import LoadingButton from '~/vue_shared/components/loading_button.vue';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders header component for job and pipeline page based on UI mockups
|
* Renders header component for job and pipeline page based on UI mockups
|
||||||
|
@ -20,7 +19,6 @@ export default {
|
||||||
UserAvatarImage,
|
UserAvatarImage,
|
||||||
GlLink,
|
GlLink,
|
||||||
GlButton,
|
GlButton,
|
||||||
LoadingButton,
|
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
GlTooltip: GlTooltipDirective,
|
GlTooltip: GlTooltipDirective,
|
||||||
|
@ -47,11 +45,6 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
actions: {
|
|
||||||
type: Array,
|
|
||||||
required: false,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
hasSidebarButton: {
|
hasSidebarButton: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
|
@ -71,9 +64,6 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
onClickAction(action) {
|
|
||||||
this.$emit('actionClicked', action);
|
|
||||||
},
|
|
||||||
onClickSidebarButton() {
|
onClickSidebarButton() {
|
||||||
this.$emit('clickedSidebarButton');
|
this.$emit('clickedSidebarButton');
|
||||||
},
|
},
|
||||||
|
@ -115,18 +105,8 @@ export default {
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="actions.length" class="header-action-buttons">
|
<section v-if="$slots.default" class="header-action-buttons">
|
||||||
<template v-for="(action, i) in actions">
|
<slot></slot>
|
||||||
<loading-button
|
|
||||||
:key="i"
|
|
||||||
:loading="action.isLoading"
|
|
||||||
:disabled="action.isLoading"
|
|
||||||
:class="action.cssClass"
|
|
||||||
container-class="d-inline"
|
|
||||||
:label="action.label"
|
|
||||||
@click="onClickAction(action)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</section>
|
</section>
|
||||||
<gl-button
|
<gl-button
|
||||||
v-if="hasSidebarButton"
|
v-if="hasSidebarButton"
|
||||||
|
|
|
@ -26,7 +26,7 @@ module MilestonesHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def milestones_issues_path(opts = {})
|
def milestones_label_path(opts = {})
|
||||||
if @project
|
if @project
|
||||||
project_issues_path(@project, opts)
|
project_issues_path(@project, opts)
|
||||||
elsif @group
|
elsif @group
|
||||||
|
@ -281,26 +281,6 @@ module MilestonesHelper
|
||||||
can?(current_user, :admin_milestone, @project.group)
|
can?(current_user, :admin_milestone, @project.group)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def display_issues_count_warning?
|
|
||||||
milestone_visible_issues_count > Milestone::DISPLAY_ISSUES_LIMIT
|
|
||||||
end
|
|
||||||
|
|
||||||
def milestone_issues_count_message
|
|
||||||
total_count = milestone_visible_issues_count
|
|
||||||
limit = Milestone::DISPLAY_ISSUES_LIMIT
|
|
||||||
|
|
||||||
message = _('Showing %{limit} of %{total_count} issues. ') % { limit: limit, total_count: total_count }
|
|
||||||
message += link_to(_('View all issues'), milestones_issues_path)
|
|
||||||
|
|
||||||
message.html_safe
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def milestone_visible_issues_count
|
|
||||||
@milestone_visible_issues_count ||= @milestone.issues_visible_to_user(current_user).size
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
MilestonesHelper.prepend_if_ee('EE::MilestonesHelper')
|
MilestonesHelper.prepend_if_ee('EE::MilestonesHelper')
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Milestoneish
|
module Milestoneish
|
||||||
DISPLAY_ISSUES_LIMIT = 20
|
|
||||||
|
|
||||||
def total_issues_count(user)
|
def total_issues_count(user)
|
||||||
count_issues_by_state(user).values.sum
|
count_issues_by_state(user).values.sum
|
||||||
end
|
end
|
||||||
|
@ -55,11 +53,7 @@ module Milestoneish
|
||||||
end
|
end
|
||||||
|
|
||||||
def sorted_issues(user)
|
def sorted_issues(user)
|
||||||
# This method is used on milestone view to filter opened assigned, opened unassigned and closed issues columns.
|
issues_visible_to_user(user).preload_associated_models.sort_by_attribute('label_priority')
|
||||||
# We want a limit of DISPLAY_ISSUES_LIMIT for total issues present on all columns.
|
|
||||||
limited_ids = issues_visible_to_user(user).limit(DISPLAY_ISSUES_LIMIT).select(:id)
|
|
||||||
|
|
||||||
Issue.where(id: limited_ids).preload_associated_models.sort_by_attribute('label_priority')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def sorted_merge_requests(user)
|
def sorted_merge_requests(user)
|
||||||
|
|
|
@ -24,6 +24,11 @@ class DeployToken < ApplicationRecord
|
||||||
message: "can contain only letters, digits, '_', '-', '+', and '.'"
|
message: "can contain only letters, digits, '_', '-', '+', and '.'"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum deploy_token_type: {
|
||||||
|
group_type: 1,
|
||||||
|
project_type: 2
|
||||||
|
}
|
||||||
|
|
||||||
before_save :ensure_token
|
before_save :ensure_token
|
||||||
|
|
||||||
accepts_nested_attributes_for :project_deploy_tokens
|
accepts_nested_attributes_for :project_deploy_tokens
|
||||||
|
|
|
@ -1,11 +1,6 @@
|
||||||
- args = { show_project_name: local_assigns.fetch(:show_project_name, false),
|
- args = { show_project_name: local_assigns.fetch(:show_project_name, false),
|
||||||
show_full_project_name: local_assigns.fetch(:show_full_project_name, false) }
|
show_full_project_name: local_assigns.fetch(:show_full_project_name, false) }
|
||||||
|
|
||||||
- if display_issues_count_warning?
|
|
||||||
.flash-container
|
|
||||||
.flash-warning#milestone-issue-count-warning
|
|
||||||
= milestone_issues_count_message
|
|
||||||
|
|
||||||
.row.prepend-top-default
|
.row.prepend-top-default
|
||||||
.col-md-4
|
.col-md-4
|
||||||
= render 'shared/milestones/issuables', args.merge(title: 'Unstarted Issues (open and unassigned)', issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true)
|
= render 'shared/milestones/issuables', args.merge(title: 'Unstarted Issues (open and unassigned)', issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true)
|
||||||
|
|
|
@ -5,12 +5,12 @@
|
||||||
%li.no-border
|
%li.no-border
|
||||||
%span.label-row
|
%span.label-row
|
||||||
%span.label-name
|
%span.label-name
|
||||||
= render_label(label, tooltip: false, link: milestones_issues_path(options))
|
= render_label(label, tooltip: false, link: milestones_label_path(options))
|
||||||
%span.prepend-description-left
|
%span.prepend-description-left
|
||||||
= markdown_field(label, :description)
|
= markdown_field(label, :description)
|
||||||
|
|
||||||
.float-right.d-none.d-lg-block.d-xl-block
|
.float-right.d-none.d-lg-block.d-xl-block
|
||||||
= link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do
|
= link_to milestones_label_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do
|
||||||
- pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue'
|
- pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue'
|
||||||
= link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do
|
= link_to milestones_label_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do
|
||||||
- pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue'
|
- pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue'
|
||||||
|
|
5
changelogs/unreleased/21765-deploy-token-add-type.yml
Normal file
5
changelogs/unreleased/21765-deploy-token-add-type.yml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Add deploy_token_type column to deploy_tokens table.
|
||||||
|
merge_request: 23530
|
||||||
|
author:
|
||||||
|
type: added
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
title: Limits issues displayed on milestones
|
|
||||||
merge_request: 23102
|
|
||||||
author:
|
|
||||||
type: performance
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Replace custom action array in CI header bar with <slot>
|
||||||
|
merge_request: 22839
|
||||||
|
author: Fabio Huser
|
||||||
|
type: other
|
5
changelogs/unreleased/update-geo-node-service.yml
Normal file
5
changelogs/unreleased/update-geo-node-service.yml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Use NodeUpdateService for updating Geo node
|
||||||
|
merge_request: 23894
|
||||||
|
author: Rajendra Kadam
|
||||||
|
type: changed
|
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddDeployTokenTypeToDeployTokens < ActiveRecord::Migration[5.2]
|
||||||
|
include Gitlab::Database::MigrationHelpers
|
||||||
|
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
DOWNTIME = false
|
||||||
|
|
||||||
|
def up
|
||||||
|
add_column_with_default :deploy_tokens, :deploy_token_type, :integer, default: 2, limit: 2, allow_null: false # rubocop: disable Migration/AddColumnWithDefault
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :deploy_tokens, :deploy_token_type
|
||||||
|
end
|
||||||
|
end
|
|
@ -1358,6 +1358,7 @@ ActiveRecord::Schema.define(version: 2020_01_27_090233) do
|
||||||
t.string "token"
|
t.string "token"
|
||||||
t.string "username"
|
t.string "username"
|
||||||
t.string "token_encrypted", limit: 255
|
t.string "token_encrypted", limit: 255
|
||||||
|
t.integer "deploy_token_type", limit: 2, default: 2, null: false
|
||||||
t.index ["token", "expires_at", "id"], name: "index_deploy_tokens_on_token_and_expires_at_and_id", where: "(revoked IS FALSE)"
|
t.index ["token", "expires_at", "id"], name: "index_deploy_tokens_on_token_and_expires_at_and_id", where: "(revoked IS FALSE)"
|
||||||
t.index ["token"], name: "index_deploy_tokens_on_token", unique: true
|
t.index ["token"], name: "index_deploy_tokens_on_token", unique: true
|
||||||
t.index ["token_encrypted"], name: "index_deploy_tokens_on_token_encrypted", unique: true
|
t.index ["token_encrypted"], name: "index_deploy_tokens_on_token_encrypted", unique: true
|
||||||
|
|
|
@ -174,7 +174,7 @@ The following documentation relates to the DevOps **Create** stage:
|
||||||
| [Delete merged branches](user/project/repository/branches/index.md#delete-merged-branches) | Bulk delete branches after their changes are merged. |
|
| [Delete merged branches](user/project/repository/branches/index.md#delete-merged-branches) | Bulk delete branches after their changes are merged. |
|
||||||
| [File templates](user/project/repository/web_editor.md#template-dropdowns) | File templates for common files. |
|
| [File templates](user/project/repository/web_editor.md#template-dropdowns) | File templates for common files. |
|
||||||
| [Files](user/project/repository/index.md#files) | Files management. |
|
| [Files](user/project/repository/index.md#files) | Files management. |
|
||||||
| [Jupyter Notebook files](user/project/repository/index.md#jupyter-notebook-files) | GitLab's support for `.ipynb` files. |
|
| [Jupyter Notebook files](user/project/repository/jupyter_notebooks/index.md#jupyter-notebook-files) | GitLab's support for `.ipynb` files. |
|
||||||
| [Protected branches](user/project/protected_branches.md) | Use protected branches. |
|
| [Protected branches](user/project/protected_branches.md) | Use protected branches. |
|
||||||
| [Push rules](push_rules/push_rules.md) **(STARTER)** | Additional control over pushes to your projects. |
|
| [Push rules](push_rules/push_rules.md) **(STARTER)** | Additional control over pushes to your projects. |
|
||||||
| [Repositories](user/project/repository/index.md) | Manage source code repositories in GitLab's user interface. |
|
| [Repositories](user/project/repository/index.md) | Manage source code repositories in GitLab's user interface. |
|
||||||
|
|
|
@ -102,19 +102,11 @@ Some things to note about precedence:
|
||||||
|
|
||||||
### Jupyter Notebook files
|
### Jupyter Notebook files
|
||||||
|
|
||||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/2508) in GitLab 9.1
|
[Jupyter](https://jupyter.org/) Notebook (previously IPython Notebook) files are used for
|
||||||
|
|
||||||
[Jupyter](https://jupyter.org) Notebook (previously IPython Notebook) files are used for
|
|
||||||
interactive computing in many fields and contain a complete record of the
|
interactive computing in many fields and contain a complete record of the
|
||||||
user's sessions and include code, narrative text, equations and rich output.
|
user's sessions and include code, narrative text, equations and rich output.
|
||||||
|
|
||||||
When added to a repository, Jupyter Notebooks with a `.ipynb` extension will be
|
[Read how to use Jupyter notebooks with GitLab.](jupyter_notebooks/index.md)
|
||||||
rendered to HTML when viewed.
|
|
||||||
|
|
||||||
![Jupyter Notebook Rich Output](img/jupyter_notebook.png)
|
|
||||||
|
|
||||||
Interactive features, including JavaScript plots, will not work when viewed in
|
|
||||||
GitLab.
|
|
||||||
|
|
||||||
### OpenAPI viewer
|
### OpenAPI viewer
|
||||||
|
|
||||||
|
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
23
doc/user/project/repository/jupyter_notebooks/index.md
Normal file
23
doc/user/project/repository/jupyter_notebooks/index.md
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Jupyter Notebook Files
|
||||||
|
|
||||||
|
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/2508/) in GitLab 9.1.
|
||||||
|
|
||||||
|
[Jupyter](https://jupyter.org/) Notebook (previously IPython Notebook) files are used for
|
||||||
|
interactive computing in many fields and contain a complete record of the
|
||||||
|
user's sessions and include code, narrative text, equations and rich output.
|
||||||
|
|
||||||
|
When added to a repository, Jupyter Notebooks with a `.ipynb` extension will be
|
||||||
|
rendered to HTML when viewed.
|
||||||
|
|
||||||
|
![Jupyter Notebook Rich Output](img/jupyter_notebook.png)
|
||||||
|
|
||||||
|
Interactive features, including JavaScript plots, will not work when viewed in
|
||||||
|
GitLab.
|
||||||
|
|
||||||
|
## Jupyter Hub as a GitLab Managed App
|
||||||
|
|
||||||
|
You can deploy [Jupyter Hub as a GitLab managed app](./../../../clusters/applications.md#jupyterhub).
|
||||||
|
|
||||||
|
## Jupyter Git integration
|
||||||
|
|
||||||
|
Find out how to [leverage JupyterLab’s Git extension on your Kubernetes cluster](./../../../clusters/applications.md#jupyter-git-integration).
|
|
@ -17254,9 +17254,6 @@ msgid_plural "Showing %d events"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
msgid "Showing %{limit} of %{total_count} issues. "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Showing %{pageSize} of %{total} issues"
|
msgid "Showing %{pageSize} of %{total} issues"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -20937,9 +20934,6 @@ msgstr ""
|
||||||
msgid "View Documentation"
|
msgid "View Documentation"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "View all issues"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "View blame prior to this change"
|
msgid "View blame prior to this change"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
|
@ -25,37 +25,6 @@ describe "User views milestone" do
|
||||||
expect { visit_milestone }.not_to exceed_query_limit(control)
|
expect { visit_milestone }.not_to exceed_query_limit(control)
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'limiting milestone issues' do
|
|
||||||
before_all do
|
|
||||||
2.times do
|
|
||||||
create(:issue, milestone: milestone, project: project)
|
|
||||||
create(:issue, milestone: milestone, project: project, assignees: [user])
|
|
||||||
create(:issue, milestone: milestone, project: project, state: :closed)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when issues on milestone are over DISPLAY_ISSUES_LIMIT' do
|
|
||||||
it "limits issues to display and shows warning" do
|
|
||||||
stub_const('Milestoneish::DISPLAY_ISSUES_LIMIT', 3)
|
|
||||||
|
|
||||||
visit(project_milestone_path(project, milestone))
|
|
||||||
|
|
||||||
expect(page).to have_selector('.issuable-row', count: 3)
|
|
||||||
expect(page).to have_selector('#milestone-issue-count-warning', text: 'Showing 3 of 6 issues. View all issues')
|
|
||||||
expect(page).to have_link('View all issues', href: project_issues_path(project))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when issues on milestone are below DISPLAY_ISSUES_LIMIT' do
|
|
||||||
it 'does not display warning' do
|
|
||||||
visit(project_milestone_path(project, milestone))
|
|
||||||
|
|
||||||
expect(page).not_to have_selector('#milestone-issue-count-warning', text: 'Showing 3 of 6 issues. View all issues')
|
|
||||||
expect(page).to have_selector('.issuable-row', count: 6)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def visit_milestone
|
def visit_milestone
|
||||||
|
|
231
spec/frontend/lib/utils/datetime_range_spec.js
Normal file
231
spec/frontend/lib/utils/datetime_range_spec.js
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { getRangeType, convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||||
|
|
||||||
|
const MOCK_NOW = Date.UTC(2020, 0, 23, 20);
|
||||||
|
|
||||||
|
const MOCK_NOW_ISO_STRING = new Date(MOCK_NOW).toISOString();
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
const defaultFixedRange = {
|
||||||
|
start: '2020-01-01T00:00:00.000Z',
|
||||||
|
end: '2020-01-31T23:59:00.000Z',
|
||||||
|
label: 'January 2020',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFixedRange = params => ({ ...defaultFixedRange, ...params });
|
||||||
|
|
||||||
|
it('converts a fixed range to an equal fixed range', () => {
|
||||||
|
const aFixedRange = mockFixedRange();
|
||||||
|
|
||||||
|
expect(convertToFixedRange(aFixedRange)).toEqual({
|
||||||
|
start: defaultFixedRange.start,
|
||||||
|
end: defaultFixedRange.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', () => {
|
||||||
|
const defaultAnchoredRange = {
|
||||||
|
anchor: '2020-01-01T00:00:00.000Z',
|
||||||
|
direction: 'after',
|
||||||
|
duration: {
|
||||||
|
seconds: 60 * 2,
|
||||||
|
},
|
||||||
|
label: 'First two minutes of 2020',
|
||||||
|
};
|
||||||
|
const mockAnchoredRange = params => ({ ...defaultAnchoredRange, ...params });
|
||||||
|
|
||||||
|
it('converts to a fixed range', () => {
|
||||||
|
const anAnchoredRange = mockAnchoredRange();
|
||||||
|
|
||||||
|
expect(convertToFixedRange(anAnchoredRange)).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', () => {
|
||||||
|
const anAnchoredRange = mockAnchoredRange({ direction: 'before' });
|
||||||
|
|
||||||
|
expect(convertToFixedRange(anAnchoredRange)).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 anAnchoredRange = _.omit(mockAnchoredRange(), 'direction');
|
||||||
|
|
||||||
|
expect(convertToFixedRange(anAnchoredRange)).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', () => {
|
||||||
|
const aRollingRange = {
|
||||||
|
direction: 'after',
|
||||||
|
duration: {
|
||||||
|
seconds: 60 * 2,
|
||||||
|
},
|
||||||
|
label: 'Next 2 minutes',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(convertToFixedRange(aRollingRange)).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 aRollingRangeWithNoDirection = {
|
||||||
|
duration: {
|
||||||
|
seconds: 60 * 2,
|
||||||
|
},
|
||||||
|
label: 'Last 2 minutes',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(convertToFixedRange(aRollingRangeWithNoDirection)).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 = {
|
||||||
|
direction: 'before',
|
||||||
|
duration: {
|
||||||
|
minutes: 20,
|
||||||
|
},
|
||||||
|
label: 'Last 20 minutes',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => convertToFixedRange(wrongDuration)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws an error when the anchor is not valid', () => {
|
||||||
|
const wrongAnchor = {
|
||||||
|
anchor: 'CAN_T_PARSE_THIS',
|
||||||
|
direction: 'after',
|
||||||
|
label: '2020 so far',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when an open range is input', () => {
|
||||||
|
it('converts to a fixed range with an `after` direction', () => {
|
||||||
|
const soFar2020 = {
|
||||||
|
anchor: '2020-01-01T00:00:00.000Z',
|
||||||
|
direction: 'after',
|
||||||
|
label: '2020 so far',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(convertToFixedRange(soFar2020)).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 before2020 = {
|
||||||
|
anchor: '2020-01-01T00:00:00.000Z',
|
||||||
|
direction: 'before',
|
||||||
|
label: 'Before 2020',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(convertToFixedRange(before2020)).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 alsoBefore2020 = {
|
||||||
|
anchor: '2020-01-01T00:00:00.000Z',
|
||||||
|
label: 'Before 2020',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(convertToFixedRange(alsoBefore2020)).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 = {
|
||||||
|
anchor: 'CAN_T_PARSE_THIS',
|
||||||
|
direction: 'after',
|
||||||
|
label: '2020 so far',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -8,6 +8,7 @@ describe('Pipeline details header', () => {
|
||||||
let props;
|
let props;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
spyOn(eventHub, '$emit');
|
||||||
HeaderComponent = Vue.extend(headerComponent);
|
HeaderComponent = Vue.extend(headerComponent);
|
||||||
|
|
||||||
const threeWeeksAgo = new Date();
|
const threeWeeksAgo = new Date();
|
||||||
|
@ -33,8 +34,9 @@ describe('Pipeline details header', () => {
|
||||||
email: 'foo@bar.com',
|
email: 'foo@bar.com',
|
||||||
avatar_url: 'link',
|
avatar_url: 'link',
|
||||||
},
|
},
|
||||||
retry_path: 'path',
|
retry_path: 'retry',
|
||||||
delete_path: 'path',
|
cancel_path: 'cancel',
|
||||||
|
delete_path: 'delete',
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
};
|
};
|
||||||
|
@ -43,9 +45,14 @@ describe('Pipeline details header', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
eventHub.$off();
|
||||||
vm.$destroy();
|
vm.$destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const findDeleteModal = () => document.getElementById(headerComponent.DELETE_MODAL_ID);
|
||||||
|
const findDeleteModalSubmit = () =>
|
||||||
|
[...findDeleteModal().querySelectorAll('.btn')].find(x => x.textContent === 'Delete pipeline');
|
||||||
|
|
||||||
it('should render provided pipeline info', () => {
|
it('should render provided pipeline info', () => {
|
||||||
expect(
|
expect(
|
||||||
vm.$el
|
vm.$el
|
||||||
|
@ -56,22 +63,46 @@ describe('Pipeline details header', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('action buttons', () => {
|
describe('action buttons', () => {
|
||||||
it('should call postAction when retry button action is clicked', done => {
|
it('should not trigger eventHub when nothing happens', () => {
|
||||||
eventHub.$on('headerPostAction', action => {
|
expect(eventHub.$emit).not.toHaveBeenCalled();
|
||||||
expect(action.path).toEqual('path');
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
vm.$el.querySelector('.js-retry-button').click();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fire modal event when delete button action is clicked', done => {
|
it('should call postAction when retry button action is clicked', () => {
|
||||||
vm.$root.$on('bv::modal::show', action => {
|
vm.$el.querySelector('.js-retry-button').click();
|
||||||
expect(action.componentId).toEqual('pipeline-delete-modal');
|
|
||||||
done();
|
expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call postAction when cancel button action is clicked', () => {
|
||||||
|
vm.$el.querySelector('.js-btn-cancel-pipeline').click();
|
||||||
|
|
||||||
|
expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete modal', () => {
|
||||||
|
expect(findDeleteModal()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when delete button action is clicked', () => {
|
||||||
|
beforeEach(done => {
|
||||||
|
vm.$el.querySelector('.js-btn-delete-pipeline').click();
|
||||||
|
|
||||||
|
// Modal needs two ticks to show
|
||||||
|
vm.$nextTick()
|
||||||
|
.then(() => vm.$nextTick())
|
||||||
|
.then(done)
|
||||||
|
.catch(done.fail);
|
||||||
});
|
});
|
||||||
|
|
||||||
vm.$el.querySelector('.js-btn-delete-pipeline').click();
|
it('should show delete modal', () => {
|
||||||
|
expect(findDeleteModal()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call delete when modal is submitted', () => {
|
||||||
|
findDeleteModalSubmit().click();
|
||||||
|
|
||||||
|
expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import mountComponent from 'spec/helpers/vue_mount_component_helper';
|
import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
|
||||||
import headerCi from '~/vue_shared/components/header_ci_component.vue';
|
import headerCi from '~/vue_shared/components/header_ci_component.vue';
|
||||||
|
|
||||||
describe('Header CI Component', () => {
|
describe('Header CI Component', () => {
|
||||||
|
@ -27,14 +27,6 @@ describe('Header CI Component', () => {
|
||||||
email: 'foo@bar.com',
|
email: 'foo@bar.com',
|
||||||
avatar_url: 'link',
|
avatar_url: 'link',
|
||||||
},
|
},
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: 'Retry',
|
|
||||||
path: 'path',
|
|
||||||
cssClass: 'btn',
|
|
||||||
isLoading: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
hasSidebarButton: true,
|
hasSidebarButton: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -43,6 +35,8 @@ describe('Header CI Component', () => {
|
||||||
vm.$destroy();
|
vm.$destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const findActionButtons = () => vm.$el.querySelector('.header-action-buttons');
|
||||||
|
|
||||||
describe('render', () => {
|
describe('render', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vm = mountComponent(HeaderCi, props);
|
vm = mountComponent(HeaderCi, props);
|
||||||
|
@ -68,25 +62,24 @@ describe('Header CI Component', () => {
|
||||||
expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name);
|
expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render provided actions', () => {
|
|
||||||
const btn = vm.$el.querySelector('.btn');
|
|
||||||
|
|
||||||
expect(btn.tagName).toEqual('BUTTON');
|
|
||||||
expect(btn.textContent.trim()).toEqual(props.actions[0].label);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading icon', done => {
|
|
||||||
vm.actions[0].isLoading = true;
|
|
||||||
|
|
||||||
Vue.nextTick(() => {
|
|
||||||
expect(vm.$el.querySelector('.btn .gl-spinner').getAttribute('style')).toBeFalsy();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render sidebar toggle button', () => {
|
it('should render sidebar toggle button', () => {
|
||||||
expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull();
|
expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not render header action buttons when empty', () => {
|
||||||
|
expect(findActionButtons()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slot', () => {
|
||||||
|
it('should render header action buttons', () => {
|
||||||
|
vm = mountComponentWithSlots(HeaderCi, { props, slots: { default: 'Test Actions' } });
|
||||||
|
|
||||||
|
const buttons = findActionButtons();
|
||||||
|
|
||||||
|
expect(buttons).not.toBeNull();
|
||||||
|
expect(buttons.textContent).toEqual('Test Actions');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('shouldRenderTriggeredLabel', () => {
|
describe('shouldRenderTriggeredLabel', () => {
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
require Rails.root.join('db', 'migrate', '20200122161638_add_deploy_token_type_to_deploy_tokens.rb')
|
||||||
|
|
||||||
|
describe AddDeployTokenTypeToDeployTokens, :migration do
|
||||||
|
let(:deploy_tokens) { table(:deploy_tokens) }
|
||||||
|
let(:deploy_token) do
|
||||||
|
deploy_tokens.create(name: 'token_test',
|
||||||
|
username: 'gitlab+deploy-token-1',
|
||||||
|
token_encrypted: 'dr8rPXwM+Mbs2p3Bg1+gpnXqrnH/wu6vaHdcc7A3isPR67WB',
|
||||||
|
read_repository: true,
|
||||||
|
expires_at: Time.now + 1.year)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the deploy_token_type column to 2' do
|
||||||
|
expect(deploy_token).not_to respond_to(:deploy_token_type)
|
||||||
|
|
||||||
|
migrate!
|
||||||
|
|
||||||
|
deploy_token.reload
|
||||||
|
expect(deploy_token.deploy_token_type).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
|
@ -33,32 +33,17 @@ describe Milestone, 'Milestoneish' do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#sorted_issues' do
|
describe '#sorted_issues' do
|
||||||
before do
|
it 'sorts issues by label priority' do
|
||||||
issue.labels << label_1
|
issue.labels << label_1
|
||||||
security_issue_1.labels << label_2
|
security_issue_1.labels << label_2
|
||||||
closed_issue_1.labels << label_3
|
closed_issue_1.labels << label_3
|
||||||
end
|
|
||||||
|
|
||||||
it 'sorts issues by label priority' do
|
|
||||||
issues = milestone.sorted_issues(member)
|
issues = milestone.sorted_issues(member)
|
||||||
|
|
||||||
expect(issues.first).to eq(issue)
|
expect(issues.first).to eq(issue)
|
||||||
expect(issues.second).to eq(security_issue_1)
|
expect(issues.second).to eq(security_issue_1)
|
||||||
expect(issues.third).not_to eq(closed_issue_1)
|
expect(issues.third).not_to eq(closed_issue_1)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'limits issue count' do
|
|
||||||
stub_const('Milestoneish::DISPLAY_ISSUES_LIMIT', 4)
|
|
||||||
|
|
||||||
issues = milestone.sorted_issues(member)
|
|
||||||
|
|
||||||
# Cannot use issues.count here because it is sorting
|
|
||||||
# by a virtual column 'highest_priority' and it will break
|
|
||||||
# the query.
|
|
||||||
total_issues_count = issues.opened.unassigned.length + issues.opened.assigned.length + issues.closed.length
|
|
||||||
expect(issues.length).to eq(4)
|
|
||||||
expect(total_issues_count).to eq(4)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'attributes visibility' do
|
context 'attributes visibility' do
|
||||||
|
|
|
@ -8,6 +8,8 @@ describe DeployToken do
|
||||||
it { is_expected.to have_many :project_deploy_tokens }
|
it { is_expected.to have_many :project_deploy_tokens }
|
||||||
it { is_expected.to have_many(:projects).through(:project_deploy_tokens) }
|
it { is_expected.to have_many(:projects).through(:project_deploy_tokens) }
|
||||||
|
|
||||||
|
it_behaves_like 'having unique enum values'
|
||||||
|
|
||||||
describe 'validations' do
|
describe 'validations' do
|
||||||
let(:username_format_message) { "can contain only letters, digits, '_', '-', '+', and '.'" }
|
let(:username_format_message) { "can contain only letters, digits, '_', '-', '+', and '.'" }
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue