Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-01-29 21:09:22 +00:00
parent 6b9d3a4e83
commit 27d314277b
27 changed files with 668 additions and 233 deletions

View 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);

View file

@ -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" />

View file

@ -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.')));
}, },

View file

@ -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"

View file

@ -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')

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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'

View file

@ -0,0 +1,5 @@
---
title: Add deploy_token_type column to deploy_tokens table.
merge_request: 23530
author:
type: added

View file

@ -1,5 +0,0 @@
---
title: Limits issues displayed on milestones
merge_request: 23102
author:
type: performance

View file

@ -0,0 +1,5 @@
---
title: Replace custom action array in CI header bar with <slot>
merge_request: 22839
author: Fabio Huser
type: other

View file

@ -0,0 +1,5 @@
---
title: Use NodeUpdateService for updating Geo node
merge_request: 23894
author: Rajendra Kadam
type: changed

View file

@ -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

View file

@ -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

View file

@ -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. |

View file

@ -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

View file

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View 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 JupyterLabs Git extension on your Kubernetes cluster](./../../../clusters/applications.md#jupyter-git-integration).

View file

@ -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 ""

View file

@ -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

View 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();
});
});
});
});

View file

@ -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');
});
}); });
}); });
}); });

View file

@ -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', () => {

View file

@ -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

View file

@ -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

View file

@ -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 '.'" }