提交 27d31427 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 6b9d3a4e
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);
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import { GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
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 { __ } from '~/locale';
......@@ -12,6 +13,10 @@ export default {
ciHeader,
GlLoadingIcon,
GlModal,
LoadingButton,
},
directives: {
GlModal: GlModalDirective,
},
props: {
pipeline: {
......@@ -25,7 +30,9 @@ export default {
},
data() {
return {
actions: this.getActions(),
isCanceling: false,
isRetrying: false,
isDeleting: false,
};
},
......@@ -43,67 +50,18 @@ export default {
},
},
watch: {
pipeline() {
this.actions = this.getActions();
},
},
methods: {
onActionClicked(action) {
if (action.modal) {
this.$root.$emit('bv::show::modal', action.modal);
} else {
this.postAction(action);
}
cancelPipeline() {
this.isCanceling = true;
eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
},
postAction(action) {
const index = this.actions.indexOf(action);
this.$set(this.actions[index], 'isLoading', true);
eventHub.$emit('headerPostAction', action);
retryPipeline() {
this.isRetrying = true;
eventHub.$emit('headerPostAction', this.pipeline.retry_path);
},
deletePipeline() {
const index = this.actions.findIndex(action => action.modal === DELETE_MODAL_ID);
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;
this.isDeleting = true;
eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
},
},
DELETE_MODAL_ID,
......@@ -117,10 +75,38 @@ export default {
:item-id="pipeline.id"
:time="pipeline.created_at"
:user="pipeline.user"
:actions="actions"
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" />
......
......@@ -70,16 +70,16 @@ export default () => {
eventHub.$off('headerDeleteAction', this.deleteAction);
},
methods: {
postAction(action) {
postAction(path) {
this.mediator.service
.postAction(action.path)
.postAction(path)
.then(() => this.mediator.refreshPipeline())
.catch(() => Flash(__('An error occurred while making the request.')));
},
deleteAction(action) {
deleteAction(path) {
this.mediator.stopPipelinePoll();
this.mediator.service
.deleteAction(action.path)
.deleteAction(path)
.then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success')))
.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 TimeagoTooltip from './time_ago_tooltip.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
......@@ -20,7 +19,6 @@ export default {
UserAvatarImage,
GlLink,
GlButton,
LoadingButton,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -47,11 +45,6 @@ export default {
required: false,
default: () => ({}),
},
actions: {
type: Array,
required: false,
default: () => [],
},
hasSidebarButton: {
type: Boolean,
required: false,
......@@ -71,9 +64,6 @@ export default {
},
methods: {
onClickAction(action) {
this.$emit('actionClicked', action);
},
onClickSidebarButton() {
this.$emit('clickedSidebarButton');
},
......@@ -115,18 +105,8 @@ export default {
</template>
</section>
<section v-if="actions.length" class="header-action-buttons">
<template v-for="(action, i) in actions">
<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 v-if="$slots.default" class="header-action-buttons">
<slot></slot>
</section>
<gl-button
v-if="hasSidebarButton"
......
......@@ -26,7 +26,7 @@ module MilestonesHelper
end
end
def milestones_issues_path(opts = {})
def milestones_label_path(opts = {})
if @project
project_issues_path(@project, opts)
elsif @group
......@@ -281,26 +281,6 @@ module MilestonesHelper
can?(current_user, :admin_milestone, @project.group)
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
MilestonesHelper.prepend_if_ee('EE::MilestonesHelper')
# frozen_string_literal: true
module Milestoneish
DISPLAY_ISSUES_LIMIT = 20
def total_issues_count(user)
count_issues_by_state(user).values.sum
end
......@@ -55,11 +53,7 @@ module Milestoneish
end
def sorted_issues(user)
# This method is used on milestone view to filter opened assigned, opened unassigned and closed issues columns.
# 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')
issues_visible_to_user(user).preload_associated_models.sort_by_attribute('label_priority')
end
def sorted_merge_requests(user)
......
......@@ -24,6 +24,11 @@ class DeployToken < ApplicationRecord
message: "can contain only letters, digits, '_', '-', '+', and '.'"
}
enum deploy_token_type: {
group_type: 1,
project_type: 2
}
before_save :ensure_token
accepts_nested_attributes_for :project_deploy_tokens
......
- args = { show_project_name: local_assigns.fetch(:show_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
.col-md-4
= 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
%span.label-row
%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
= markdown_field(label, :description)
.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'
= 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'
---
title: Add deploy_token_type column to deploy_tokens table.
merge_request: 23530
author:
type: added
---
title: Limits issues displayed on milestones
merge_request: 23102
author:
type: performance
---
title: Replace custom action array in CI header bar with <slot>
merge_request: 22839
author: Fabio Huser
type: other
---
title: Use NodeUpdateService for updating Geo node
merge_request: 23894
author: Rajendra Kadam
type: changed
# 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 "username"
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"], name: "index_deploy_tokens_on_token", 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. |
| [File templates](user/project/repository/web_editor.md#template-dropdowns) | File templates for common files. |
| [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. |
| [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. |
......
......@@ -102,19 +102,11 @@ Some things to note about precedence:
### 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
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.
[Read how to use Jupyter notebooks with GitLab.](jupyter_notebooks/index.md)
### OpenAPI viewer
......
# 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[1] ""
msgid "Showing %{limit} of %{total_count} issues. "
msgstr ""
msgid "Showing %{pageSize} of %{total} issues"
msgstr ""
......@@ -20937,9 +20934,6 @@ msgstr ""
msgid "View Documentation"
msgstr ""
msgid "View all issues"
msgstr ""
msgid "View blame prior to this change"
msgstr ""
......
......@@ -25,37 +25,6 @@ describe "User views milestone" do
expect { visit_milestone }.not_to exceed_query_limit(control)
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
def visit_milestone
......
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;
beforeEach(() => {
spyOn(eventHub, '$emit');
HeaderComponent = Vue.extend(headerComponent);
const threeWeeksAgo = new Date();
......@@ -33,8 +34,9 @@ describe('Pipeline details header', () => {
email: 'foo@bar.com',
avatar_url: 'link',
},
retry_path: 'path',
delete_path: 'path',
retry_path: 'retry',
cancel_path: 'cancel',
delete_path: 'delete',
},
isLoading: false,
};
......@@ -43,9 +45,14 @@ describe('Pipeline details header', () => {
});
afterEach(() => {
eventHub.$off();
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', () => {
expect(
vm.$el
......@@ -56,22 +63,46 @@ describe('Pipeline details header', () => {
});
describe('action buttons', () => {
it('should call postAction when retry button action is clicked', done => {
eventHub.$on('headerPostAction', action => {
expect(action.path).toEqual('path');
done();
});
it('should not trigger eventHub when nothing happens', () => {
expect(eventHub.$emit).not.toHaveBeenCalled();
});
it('should call postAction when retry button action is clicked', () => {
vm.$el.querySelector('.js-retry-button').click();
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('should fire modal event when delete button action is clicked', done => {
vm.$root.$on('bv::modal::show', action => {
expect(action.componentId).toEqual('pipeline-delete-modal');
done();
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');
});
});
});
});
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';
describe('Header CI Component', () => {
......@@ -27,14 +27,6 @@ describe('Header CI Component', () => {
email: 'foo@bar.com',
avatar_url: 'link',
},
actions: [
{
label: 'Retry',
path: 'path',
cssClass: 'btn',
isLoading: false,
},
],
hasSidebarButton: true,
};
});
......@@ -43,6 +35,8 @@ describe('Header CI Component', () => {
vm.$destroy();
});
const findActionButtons = () => vm.$el.querySelector('.header-action-buttons');
describe('render', () => {
beforeEach(() => {
vm = mountComponent(HeaderCi, props);
......@@ -68,24 +62,23 @@ describe('Header CI Component', () => {
expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name);
});
it('should render provided actions', () => {
const btn = vm.$el.querySelector('.btn');
it('should render sidebar toggle button', () => {
expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull();
});
expect(btn.tagName).toEqual('BUTTON');
expect(btn.textContent.trim()).toEqual(props.actions[0].label);
it('should not render header action buttons when empty', () => {
expect(findActionButtons()).toBeNull();
});
});
it('should show loading icon', done => {
vm.actions[0].isLoading = true;
describe('slot', () => {
it('should render header action buttons', () => {
vm = mountComponentWithSlots(HeaderCi, { props, slots: { default: 'Test Actions' } });
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn .gl-spinner').getAttribute('style')).toBeFalsy();
done();
});
});
const buttons = findActionButtons();
it('should render sidebar toggle button', () => {
expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull();
expect(buttons).not.toBeNull();
expect(buttons.textContent).toEqual('Test Actions');
});
});
......
# 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
describe '#sorted_issues' do
before do
it 'sorts issues by label priority' do
issue.labels << label_1
security_issue_1.labels << label_2
closed_issue_1.labels << label_3
end
it 'sorts issues by label priority' do
issues = milestone.sorted_issues(member)
expect(issues.first).to eq(issue)
expect(issues.second).to eq(security_issue_1)
expect(issues.third).not_to eq(closed_issue_1)
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
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(:projects).through(:project_deploy_tokens) }
it_behaves_like 'having unique enum values'
describe 'validations' do
let(:username_format_message) { "can contain only letters, digits, '_', '-', '+', and '.'" }
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册