diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-01-30 00:09:22 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-01-30 00:09:22 +0300 |
commit | 27d314277bfe7fffec215efa9b1833a23bb82940 (patch) | |
tree | 898c606409718e70579beea62174624f84e28629 | |
parent | 6b9d3a4e8351e662c4586b24bb152de78ae9e3bf (diff) |
Add latest changes from gitlab-org/gitlab@master
27 files changed, 663 insertions, 228 deletions
diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js new file mode 100644 index 00000000000..53b8702afa7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime_range.js @@ -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); diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 726bba7f9f4..2a3d022c5cd 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,6 +1,7 @@ <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" /> diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index c874c4c6fdd..4ae3e813c36 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -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.'))); }, diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index dba4a9231a1..876eb7b899c 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -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" diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 494c0bee8b8..b12b39073ef 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -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') diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 9ff60003406..88e752e51e7 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -1,8 +1,6 @@ # 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) diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 20e1d802178..3d098406ab1 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -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 diff --git a/app/views/shared/milestones/_issues_tab.html.haml b/app/views/shared/milestones/_issues_tab.html.haml index 52ce0482cd0..a8db7f8a556 100644 --- a/app/views/shared/milestones/_issues_tab.html.haml +++ b/app/views/shared/milestones/_issues_tab.html.haml @@ -1,11 +1,6 @@ - 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) diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index cdea15bf13e..ecab037e378 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -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' diff --git a/changelogs/unreleased/21765-deploy-token-add-type.yml b/changelogs/unreleased/21765-deploy-token-add-type.yml new file mode 100644 index 00000000000..317aa1d1e7e --- /dev/null +++ b/changelogs/unreleased/21765-deploy-token-add-type.yml @@ -0,0 +1,5 @@ +--- +title: Add deploy_token_type column to deploy_tokens table. +merge_request: 23530 +author: +type: added diff --git a/changelogs/unreleased/issue_39453.yml b/changelogs/unreleased/issue_39453.yml deleted file mode 100644 index c82444009ed..00000000000 --- a/changelogs/unreleased/issue_39453.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Limits issues displayed on milestones -merge_request: 23102 -author: -type: performance diff --git a/changelogs/unreleased/refactor-pipeline-header-slot-buttons.yml b/changelogs/unreleased/refactor-pipeline-header-slot-buttons.yml new file mode 100644 index 00000000000..ee5425cdb74 --- /dev/null +++ b/changelogs/unreleased/refactor-pipeline-header-slot-buttons.yml @@ -0,0 +1,5 @@ +--- +title: Replace custom action array in CI header bar with <slot> +merge_request: 22839 +author: Fabio Huser +type: other diff --git a/changelogs/unreleased/update-geo-node-service.yml b/changelogs/unreleased/update-geo-node-service.yml new file mode 100644 index 00000000000..bed0ccc6c82 --- /dev/null +++ b/changelogs/unreleased/update-geo-node-service.yml @@ -0,0 +1,5 @@ +--- +title: Use NodeUpdateService for updating Geo node +merge_request: 23894 +author: Rajendra Kadam +type: changed diff --git a/db/migrate/20200122161638_add_deploy_token_type_to_deploy_tokens.rb b/db/migrate/20200122161638_add_deploy_token_type_to_deploy_tokens.rb new file mode 100644 index 00000000000..e0cf18caf9c --- /dev/null +++ b/db/migrate/20200122161638_add_deploy_token_type_to_deploy_tokens.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index cb27967e69c..80e7af66fb9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/doc/README.md b/doc/README.md index c3db960514f..201a2b57fbd 100644 --- a/doc/README.md +++ b/doc/README.md @@ -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. | diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md index fad8cbe81cb..8be7320dcb9 100644 --- a/doc/user/project/repository/index.md +++ b/doc/user/project/repository/index.md @@ -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 diff --git a/doc/user/project/repository/img/jupyter_notebook.png b/doc/user/project/repository/jupyter_notebooks/img/jupyter_notebook.png Binary files differindex 52c5c5aea32..52c5c5aea32 100644 --- a/doc/user/project/repository/img/jupyter_notebook.png +++ b/doc/user/project/repository/jupyter_notebooks/img/jupyter_notebook.png diff --git a/doc/user/project/repository/jupyter_notebooks/index.md b/doc/user/project/repository/jupyter_notebooks/index.md new file mode 100644 index 00000000000..6b93ee05a9b --- /dev/null +++ b/doc/user/project/repository/jupyter_notebooks/index.md @@ -0,0 +1,23 @@ +# Jupyter Notebook Files + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/2508/) in GitLab 9.1. + +[Jupyter](https://jupyter.org/) Notebook (previously IPython Notebook) files are used for +interactive computing in many fields and contain a complete record of the +user's sessions and include code, narrative text, equations and rich output. + +When added to a repository, Jupyter Notebooks with a `.ipynb` extension will be +rendered to HTML when viewed. + +![Jupyter Notebook Rich Output](img/jupyter_notebook.png) + +Interactive features, including JavaScript plots, will not work when viewed in +GitLab. + +## Jupyter Hub as a GitLab Managed App + +You can deploy [Jupyter Hub as a GitLab managed app](./../../../clusters/applications.md#jupyterhub). + +## Jupyter Git integration + +Find out how to [leverage JupyterLab’s Git extension on your Kubernetes cluster](./../../../clusters/applications.md#jupyter-git-integration). diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 52f9231dffa..5d2c19a0fb8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/features/milestones/user_views_milestone_spec.rb b/spec/features/milestones/user_views_milestone_spec.rb index b1c2a87ef94..71abb195ad1 100644 --- a/spec/features/milestones/user_views_milestone_spec.rb +++ b/spec/features/milestones/user_views_milestone_spec.rb @@ -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 diff --git a/spec/frontend/lib/utils/datetime_range_spec.js b/spec/frontend/lib/utils/datetime_range_spec.js new file mode 100644 index 00000000000..13eb69e1761 --- /dev/null +++ b/spec/frontend/lib/utils/datetime_range_spec.js @@ -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(); + }); + }); + }); +}); diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js index 8c033447ce4..9043f30397d 100644 --- a/spec/javascripts/pipelines/header_component_spec.js +++ b/spec/javascripts/pipelines/header_component_spec.js @@ -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'); + }); }); }); }); diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js index ea2eed2886a..b1abc972e1d 100644 --- a/spec/javascripts/vue_shared/components/header_ci_component_spec.js +++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js @@ -1,5 +1,5 @@ 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'); }); }); diff --git a/spec/migrations/add_deploy_token_type_to_deploy_tokens_spec.rb b/spec/migrations/add_deploy_token_type_to_deploy_tokens_spec.rb new file mode 100644 index 00000000000..fb8213a6bd6 --- /dev/null +++ b/spec/migrations/add_deploy_token_type_to_deploy_tokens_spec.rb @@ -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 diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb index e39cbedde68..d46c9747845 100644 --- a/spec/models/concerns/milestoneish_spec.rb +++ b/spec/models/concerns/milestoneish_spec.rb @@ -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 diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb index 8d951ab6f0f..5c14d57cf18 100644 --- a/spec/models/deploy_token_spec.rb +++ b/spec/models/deploy_token_spec.rb @@ -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 '.'" } |