Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/lib/utils/datetime_range.js223
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue112
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue24
-rw-r--r--app/helpers/milestones_helper.rb22
-rw-r--r--app/models/concerns/milestoneish.rb8
-rw-r--r--app/models/deploy_token.rb5
-rw-r--r--app/views/shared/milestones/_issues_tab.html.haml5
-rw-r--r--app/views/shared/milestones/_labels_tab.html.haml6
-rw-r--r--changelogs/unreleased/21765-deploy-token-add-type.yml5
-rw-r--r--changelogs/unreleased/issue_39453.yml5
-rw-r--r--changelogs/unreleased/refactor-pipeline-header-slot-buttons.yml5
-rw-r--r--changelogs/unreleased/update-geo-node-service.yml5
-rw-r--r--db/migrate/20200122161638_add_deploy_token_type_to_deploy_tokens.rb17
-rw-r--r--db/schema.rb1
-rw-r--r--doc/README.md2
-rw-r--r--doc/user/project/repository/index.md12
-rw-r--r--doc/user/project/repository/jupyter_notebooks/img/jupyter_notebook.png (renamed from doc/user/project/repository/img/jupyter_notebook.png)bin63326 -> 63326 bytes
-rw-r--r--doc/user/project/repository/jupyter_notebooks/index.md23
-rw-r--r--locale/gitlab.pot6
-rw-r--r--spec/features/milestones/user_views_milestone_spec.rb31
-rw-r--r--spec/frontend/lib/utils/datetime_range_spec.js231
-rw-r--r--spec/javascripts/pipelines/header_component_spec.js55
-rw-r--r--spec/javascripts/vue_shared/components/header_ci_component_spec.js37
-rw-r--r--spec/migrations/add_deploy_token_type_to_deploy_tokens_spec.rb24
-rw-r--r--spec/models/concerns/milestoneish_spec.rb17
-rw-r--r--spec/models/deploy_token_spec.rb2
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
index 52c5c5aea32..52c5c5aea32 100644
--- a/doc/user/project/repository/img/jupyter_notebook.png
+++ b/doc/user/project/repository/jupyter_notebooks/img/jupyter_notebook.png
Binary files differ
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 '.'" }