Commit 27d31427 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 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> <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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 () => { ...@@ -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'; ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 ...@@ -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 ...@@ -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')
# 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 ...@@ -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 ...@@ -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
......
- 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 @@ ...@@ -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'
---
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 ...@@ -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: ...@@ -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: ...@@ -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
......
# 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" ...@@ -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 "" ...@@ -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 ...@@ -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
......
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', () => { ...@@ -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', () => { ...@@ -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', () => { ...@@ -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', () => { ...@@ -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();
});
it('should call postAction when retry button action is clicked', () => {
vm.$el.querySelector('.js-retry-button').click(); 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 => { it('does not show delete modal', () => {
vm.$root.$on('bv::modal::show', action => { expect(findDeleteModal()).not.toBeVisible();
expect(action.componentId).toEqual('pipeline-delete-modal'); });
done();
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 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', () => { ...@@ -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', () => { ...@@ -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,24 +62,23 @@ describe('Header CI Component', () => { ...@@ -68,24 +62,23 @@ 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', () => { it('should render sidebar toggle button', () => {
const btn = vm.$el.querySelector('.btn'); expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull();
});
expect(btn.tagName).toEqual('BUTTON'); it('should not render header action buttons when empty', () => {
expect(btn.textContent.trim()).toEqual(props.actions[0].label); expect(findActionButtons()).toBeNull();
}); });
});
it('should show loading icon', done => { describe('slot', () => {
vm.actions[0].isLoading = true; it('should render header action buttons', () => {
vm = mountComponentWithSlots(HeaderCi, { props, slots: { default: 'Test Actions' } });
Vue.nextTick(() => { const buttons = findActionButtons();
expect(vm.$el.querySelector('.btn .gl-spinner').getAttribute('style')).toBeFalsy();
done();
});
});
it('should render sidebar toggle button', () => { expect(buttons).not.toBeNull();
expect(vm.$el.querySelector('.js-sidebar-build-toggle')).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 ...@@ -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 ...@@ -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 '.'" }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment