Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-05-03 12:07:28 +00:00
parent 4df4a22481
commit 5fabe42e23
54 changed files with 11329 additions and 199 deletions

1
.gitignore vendored
View File

@ -53,6 +53,7 @@ eslint-report.html
/db/data.yml
/doc/code/*
/dump.rdb
/glfm_specification/input/github_flavored_markdown/ghfm_spec_v_*.txt
/jsconfig.json
/lefthook-local.yml
/log/*.log*

View File

@ -273,7 +273,7 @@ export default {
return IssuableStatusText[this.issuableStatus];
},
shouldShowStickyHeader() {
return this.issuableType === IssuableType.Issue;
return [IssuableType.Issue, IssuableType.Epic].includes(this.issuableType);
},
},
created() {

View File

@ -1,5 +1,5 @@
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { GlButton, GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui';
import eventHub from '~/blob/components/eventhub';
import createFlash from '~/flash';
@ -11,7 +11,6 @@ import {
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import TitleField from '~/vue_shared/components/form/title.vue';
import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '../constants';
import { getSnippetMixin } from '../mixins/snippets';
@ -31,10 +30,11 @@ export default {
SnippetDescriptionEdit,
SnippetVisibilityEdit,
SnippetBlobActionsEdit,
TitleField,
FormFooterActions,
GlButton,
GlLoadingIcon,
GlFormInput,
GlFormGroup,
},
mixins: [getSnippetMixin],
inject: ['selectedLevel'],
@ -67,6 +67,7 @@ export default {
description: '',
visibilityLevel: this.selectedLevel,
},
showValidation: false,
};
},
computed: {
@ -85,8 +86,11 @@ export default {
hasValidBlobs() {
return this.actions.every((x) => x.content);
},
updatePrevented() {
return this.snippet.title === '' || !this.hasValidBlobs || this.isUpdating;
isTitleValid() {
return this.snippet.title !== '';
},
isFormValid() {
return this.isTitleValid && this.hasValidBlobs;
},
isProjectSnippet() {
return Boolean(this.projectPath);
@ -112,6 +116,12 @@ export default {
}
return this.snippet.webUrl;
},
shouldShowBlobsErrors() {
return this.showValidation && !this.hasValidBlobs;
},
shouldShowTitleErrors() {
return this.showValidation && !this.isTitleValid;
},
},
beforeCreate() {
performanceMarkAndMeasure({ mark: SNIPPET_MARK_EDIT_APP_START });
@ -165,6 +175,12 @@ export default {
};
},
handleFormSubmit() {
this.showValidation = true;
if (!this.isFormValid) {
return;
}
this.isUpdating = true;
this.$apollo
@ -206,19 +222,31 @@ export default {
class="loading-animation prepend-top-20 gl-mb-6"
/>
<template v-else>
<title-field
id="snippet-title"
v-model="snippet.title"
data-qa-selector="snippet_title_field"
required
:autofocus="true"
/>
<gl-form-group
:label="__('Title')"
label-for="snippet-title"
:invalid-feedback="__('This field is required.')"
:state="!shouldShowTitleErrors"
>
<gl-form-input
id="snippet-title"
v-model="snippet.title"
data-testid="snippet-title-input"
data-qa-selector="snippet_title_field"
:autofocus="true"
/>
</gl-form-group>
<snippet-description-edit
v-model="snippet.description"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
/>
<snippet-blob-actions-edit :init-blobs="blobs" @actions="updateActions" />
<snippet-blob-actions-edit
:init-blobs="blobs"
:is-valid="!shouldShowBlobsErrors"
@actions="updateActions"
/>
<snippet-visibility-edit
v-model="snippet.visibilityLevel"
@ -228,12 +256,13 @@ export default {
<form-footer-actions>
<template #prepend>
<gl-button
class="js-no-auto-disable"
category="primary"
type="submit"
variant="confirm"
:disabled="updatePrevented"
data-qa-selector="submit_button"
data-testid="snippet-submit-btn"
:disabled="isUpdating"
>{{ saveButtonLabel }}</gl-button
>
</template>

View File

@ -1,5 +1,5 @@
<script>
import { GlButton } from '@gitlab/ui';
import { GlButton, GlFormGroup } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { s__, sprintf } from '~/locale';
import { SNIPPET_MAX_BLOBS } from '../constants';
@ -10,12 +10,18 @@ export default {
components: {
SnippetBlobEdit,
GlButton,
GlFormGroup,
},
props: {
initBlobs: {
type: Array,
required: true,
},
isValid: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
@ -124,16 +130,26 @@ export default {
</script>
<template>
<div class="form-group">
<label :for="firstInputId">{{ s__('Snippets|Files') }}</label>
<snippet-blob-edit
v-for="(blobId, index) in blobIds"
:key="blobId"
:class="{ 'gl-mt-3': index > 0 }"
:blob="blobs[blobId]"
:can-delete="canDelete"
@blob-updated="updateBlob(blobId, $event)"
@delete="deleteBlob(blobId)"
/>
<gl-form-group
:label="s__('Snippets|Files')"
:label-for="firstInputId"
:invalid-feedback="
s__(
'Snippets|Snippets can\'t contain empty files. Ensure all files have content, or delete them.',
)
"
:state="isValid"
>
<snippet-blob-edit
v-for="(blobId, index) in blobIds"
:key="blobId"
:class="{ 'gl-mt-3': index > 0 }"
:blob="blobs[blobId]"
:can-delete="canDelete"
@blob-updated="updateBlob(blobId, $event)"
@delete="deleteBlob(blobId)"
/>
</gl-form-group>
<gl-button
:disabled="!canAdd"
data-testid="add_button"

View File

@ -1,5 +1,5 @@
<script>
import { GlForm, GlFormInput } from '@gitlab/ui';
import { GlForm, GlFormInput, GlFormGroup } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
@ -9,6 +9,7 @@ export default {
components: {
GlForm,
GlFormInput,
GlFormGroup,
MarkdownField,
LabelsSelect,
},
@ -37,6 +38,7 @@ export default {
selectedLabels: [],
};
},
computed: {},
methods: {
handleUpdateSelectedLabels(labels) {
if (labels.length) {
@ -52,12 +54,15 @@ export default {
<div data-testid="issuable-title" class="form-group row">
<label for="issuable-title" class="col-form-label col-sm-2">{{ __('Title') }}</label>
<div class="col-sm-10">
<gl-form-input
id="issuable-title"
v-model="issuableTitle"
:autofocus="true"
:placeholder="__('Title')"
/>
<gl-form-group :description="__('Maximum of 255 characters')">
<gl-form-input
id="issuable-title"
v-model="issuableTitle"
maxlength="255"
:autofocus="true"
:placeholder="__('Title')"
/>
</gl-form-group>
</div>
</div>
<div data-testid="issuable-description" class="form-group row">

View File

@ -188,7 +188,7 @@ export default {
<form @submit.prevent="createWorkItem">
<gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert>
<div :class="{ 'gl-px-5': isModal }" data-testid="content">
<item-title :title="title" data-testid="title-input" @title-input="handleTitleInput" />
<item-title :title="initialTitle" data-testid="title-input" @title-input="handleTitleInput" />
<div>
<gl-loading-icon
v-if="$apollo.queries.workItemTypes.loading"

View File

@ -7,7 +7,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
}
.design-detail {
background-color: rgba($black, 0.9);
background-color: rgba($modal-backdrop-bg, $modal-backdrop-opacity);
.with-performance-bar & {
top: 35px;

View File

@ -453,7 +453,7 @@ $border-radius-small: 2px;
$border-radius-large: 8px;
$default-icon-size: 16px;
$layout-link-gray: #7e7c7c;
$btn-side-margin: 10px;
$btn-side-margin: $grid-size;
$btn-sm-side-margin: 7px;
$btn-margin-5: 5px;
$count-arrow-border: #dce0e5;

View File

@ -191,7 +191,7 @@
}
.board-card {
background: var(--white, $white);
background: var(--gray-10, $white);
box-shadow: 0 1px 2px rgba(var(--black, $black), 0.1);
line-height: $gl-padding;
list-style: none;

View File

@ -0,0 +1,73 @@
# frozen_string_literal: true
module Mutations
module WorkItems
class DeleteTask < BaseMutation
graphql_name 'WorkItemDeleteTask'
description "Deletes a task in a work item's description." \
' Available only when feature flag `work_items` is enabled. This feature is experimental and' \
' is subject to change without notice.'
authorize :update_work_item
argument :id, ::Types::GlobalIDType[::WorkItem],
required: true,
description: 'Global ID of the work item.'
argument :lock_version, GraphQL::Types::Int,
required: true,
description: 'Current lock version of the work item containing the task in the description.'
argument :task_data, ::Types::WorkItems::DeletedTaskInputType,
required: true,
description: 'Arguments necessary to delete a task from a work item\'s description.',
prepare: ->(attributes, _ctx) { attributes.to_h }
field :work_item, Types::WorkItemType,
null: true,
description: 'Updated work item.'
def resolve(id:, lock_version:, task_data:)
work_item = authorized_find!(id: id)
task_data[:task] = authorized_find_task!(task_data[:id])
unless work_item.project.work_items_feature_flag_enabled?
return { errors: ['`work_items` feature flag disabled for this project'] }
end
result = ::WorkItems::DeleteTaskService.new(
work_item: work_item,
current_user: current_user,
lock_version: lock_version,
task_params: task_data
).execute
response = { errors: result.errors }
response[:work_item] = work_item if result.success?
response
end
private
def authorized_find_task!(task_id)
# TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883
task_id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(task_id)
task = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(task_id))
if current_user.can?(:delete_work_item, task)
task
else
# Fail early if user cannot delete task
raise_resource_not_available_error!
end
end
# method used by `authorized_find!(id: id)`
def find_object(id:)
# TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end

View File

@ -131,6 +131,7 @@ module Types
mount_mutation Mutations::WorkItems::Create
mount_mutation Mutations::WorkItems::CreateFromTask
mount_mutation Mutations::WorkItems::Delete
mount_mutation Mutations::WorkItems::DeleteTask
mount_mutation Mutations::WorkItems::Update
mount_mutation Mutations::SavedReplies::Create
mount_mutation Mutations::SavedReplies::Update

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Types
module WorkItems
class DeletedTaskInputType < BaseInputObject
graphql_name 'WorkItemDeletedTaskInput'
argument :id, ::Types::GlobalIDType[::WorkItem],
required: true,
description: 'Global ID of the task referenced in the work item\'s description.'
argument :line_number_end, GraphQL::Types::Int,
required: true,
description: 'Last line in the Markdown source that defines the list item task.'
argument :line_number_start, GraphQL::Types::Int,
required: true,
description: 'First line in the Markdown source that defines the list item task.'
end
end
end

View File

@ -208,11 +208,11 @@ module MergeRequestsHelper
def how_merge_modal_data(merge_request)
{
is_fork: merge_request.for_fork?,
can_merge: merge_request.can_be_merged_by?(current_user),
is_fork: merge_request.for_fork?.to_s,
can_merge: merge_request.can_be_merged_by?(current_user).to_s,
source_branch: merge_request.source_branch,
source_project: merge_request.source_project,
source_project_full_path: merge_request.source_project&.full_path,
source_project_default_url: merge_request.source_project && default_url_to_repo(merge_request.source_project),
target_branch: merge_request.target_branch,
reviewing_docs_path: help_page_path('user/project/merge_requests/reviews/index.md', anchor: "checkout-merge-requests-locally-through-the-head-ref")
}

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
module WorkItems
class DeleteTaskService
def initialize(work_item:, current_user: nil, task_params: {}, lock_version:)
@work_item = work_item
@current_user = current_user
@task_params = task_params
@lock_version = lock_version
@task = task_params[:task]
@errors = []
end
def execute
transaction_result = ::WorkItem.transaction do
replacement_result = TaskListReferenceRemovalService.new(
work_item: @work_item,
task: @task,
line_number_start: @task_params[:line_number_start],
line_number_end: @task_params[:line_number_end],
lock_version: @lock_version,
current_user: @current_user
).execute
break ::ServiceResponse.error(message: replacement_result.errors, http_status: 422) if replacement_result.error?
delete_result = ::WorkItems::DeleteService.new(
project: @task.project,
current_user: @current_user
).execute(@task)
if delete_result.error?
@errors += delete_result.errors
raise ActiveRecord::Rollback
end
delete_result
end
return transaction_result if transaction_result
::ServiceResponse.error(message: @errors, http_status: 422)
end
end
end

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
module WorkItems
class TaskListReferenceRemovalService
STALE_OBJECT_MESSAGE = 'Stale work item. Check lock version'
def initialize(work_item:, task:, line_number_start:, line_number_end:, lock_version:, current_user:)
@work_item = work_item
@task = task
@line_number_start = line_number_start
@line_number_end = line_number_end
@lock_version = lock_version
@current_user = current_user
end
def execute
return ::ServiceResponse.error(message: 'line_number_start must be greater than 0') if @line_number_start < 1
return ::ServiceResponse.error(message: "Work item description can't be blank") if @work_item.description.blank?
if @line_number_end < @line_number_start
return ::ServiceResponse.error(message: 'line_number_end must be greater or equal to line_number_start')
end
source_lines = @work_item.description.split("\n")
line_matches_reference = (@line_number_start..@line_number_end).any? do |line_number|
markdown_line = source_lines[line_number - 1]
/#{Regexp.escape(@task.to_reference)}(?!\d)/.match?(markdown_line)
end
unless line_matches_reference
return ::ServiceResponse.error(
message: "Unable to detect a task on lines #{@line_number_start}-#{@line_number_end}"
)
end
remove_task_lines!(source_lines)
::WorkItems::UpdateService.new(
project: @work_item.project,
current_user: @current_user,
params: { description: source_lines.join("\n"), lock_version: @lock_version }
).execute(@work_item)
if @work_item.valid?
::ServiceResponse.success
else
::ServiceResponse.error(message: @work_item.errors.full_messages)
end
rescue ActiveRecord::StaleObjectError
::ServiceResponse.error(message: STALE_OBJECT_MESSAGE)
end
private
def remove_task_lines!(source_lines)
source_lines.delete_if.each_with_index do |_line, index|
index >= @line_number_start - 1 && index < @line_number_end
end
end
end
end

View File

@ -1,8 +1,8 @@
- name: "Permissions change for downloading Composer dependencies"
announcement_milestone: "14.9"
announcement_date: "2022-03-22"
removal_milestone: "15.0"
removal_date: "2022-05-22"
removal_milestone: "14.10"
removal_date: "2022-04-22"
breaking_change: true
reporter: trizzi
body: | # Do not modify this line, instead modify the lines below.

View File

@ -0,0 +1,12 @@
- name: "Permissions change for downloading Composer dependencies"
announcement_milestone: "14.9"
announcement_date: "2022-03-22"
removal_milestone: "14.10"
removal_date: "2022-04-22"
breaking_change: true
reporter: trizzi
body: | # Do not modify this line, instead modify the lines below.
The GitLab Composer repository can be used to push, search, fetch metadata about, and download PHP dependencies. All these actions require authentication, except for downloading dependencies.
Downloading Composer dependencies without authentication is deprecated in GitLab 14.9, and will be removed in GitLab 15.0. Starting with GitLab 15.0, you must authenticate to download Composer dependencies.
stage: package

View File

@ -2281,7 +2281,7 @@ The following tables and diagram detail the hybrid environment using the same fo
as the normal environment above.
First are the components that run in Kubernetes. The recommendation at this time is to
use Google Cloud's Kubernetes Engine (GKE) and associated machine types, but the memory
use Google Cloud's Kubernetes Engine (GKE) or AWS Elastic Kubernetes Service (EKS) and associated machine types, but the memory
and CPU requirements should translate to most other providers. We hope to update this in the
future with further specific cloud provider details.

View File

@ -2279,7 +2279,7 @@ The following tables and diagram detail the hybrid environment using the same fo
as the normal environment above.
First are the components that run in Kubernetes. The recommendation at this time is to
use Google Cloud's Kubernetes Engine (GKE) and associated machine types, but the memory
use Google Cloud's Kubernetes Engine (GKE) or AWS Elastic Kubernetes Service (EKS) and associated machine types, but the memory
and CPU requirements should translate to most other providers. We hope to update this in the
future with further specific cloud provider details.

View File

@ -1000,7 +1000,7 @@ The following tables and diagram detail the hybrid environment using the same fo
as the normal environment above.
First are the components that run in Kubernetes. The recommendation at this time is to
use Google Cloud's Kubernetes Engine (GKE) and associated machine types, but the memory
use Google Cloud's Kubernetes Engine (GKE) or AWS Elastic Kubernetes Service (EKS) and associated machine types, but the memory
and CPU requirements should translate to most other providers. We hope to update this in the
future with further specific cloud provider details.

View File

@ -2239,7 +2239,7 @@ The following tables and diagram detail the hybrid environment using the same fo
as the normal environment above.
First are the components that run in Kubernetes. The recommendation at this time is to
use Google Cloud's Kubernetes Engine (GKE) and associated machine types, but the memory
use Google Cloud's Kubernetes Engine (GKE) or AWS Elastic Kubernetes Service (EKS) and associated machine types, but the memory
and CPU requirements should translate to most other providers. We hope to update this in the
future with further specific cloud provider details.

View File

@ -2295,7 +2295,7 @@ The following tables and diagram detail the hybrid environment using the same fo
as the normal environment above.
First are the components that run in Kubernetes. The recommendation at this time is to
use Google Cloud's Kubernetes Engine (GKE) and associated machine types, but the memory
use Google Cloud's Kubernetes Engine (GKE) or AWS Elastic Kubernetes Service (EKS) and associated machine types, but the memory
and CPU requirements should translate to most other providers. We hope to update this in the
future with further specific cloud provider details.

View File

@ -2214,7 +2214,7 @@ The following tables and diagram detail the hybrid environment using the same fo
as the normal environment above.
First are the components that run in Kubernetes. The recommendation at this time is to
use Google Cloud's Kubernetes Engine (GKE) and associated machine types, but the memory
use Google Cloud's Kubernetes Engine (GKE) or AWS Elastic Kubernetes Service (EKS) and associated machine types, but the memory
and CPU requirements should translate to most other providers. We hope to update this in the
future with further specific cloud provider details.

View File

@ -5371,6 +5371,29 @@ Input type: `WorkItemDeleteInput`
| <a id="mutationworkitemdeleteerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemdeleteproject"></a>`project` | [`Project`](#project) | Project the deleted work item belonged to. |
### `Mutation.workItemDeleteTask`
Deletes a task in a work item's description. Available only when feature flag `work_items` is enabled. This feature is experimental and is subject to change without notice.
Input type: `WorkItemDeleteTaskInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemdeletetaskclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemdeletetaskid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
| <a id="mutationworkitemdeletetasklockversion"></a>`lockVersion` | [`Int!`](#int) | Current lock version of the work item containing the task in the description. |
| <a id="mutationworkitemdeletetasktaskdata"></a>`taskData` | [`WorkItemDeletedTaskInput!`](#workitemdeletedtaskinput) | Arguments necessary to delete a task from a work item's description. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemdeletetaskclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemdeletetaskerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemdeletetaskworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Updated work item. |
### `Mutation.workItemUpdate`
Updates a work item by Global ID. Available only when feature flag `work_items` is enabled. The feature is experimental and is subject to change without notice.
@ -21033,3 +21056,13 @@ A time-frame defined as a closed inclusive range of two dates.
| <a id="workitemconverttaskinputlockversion"></a>`lockVersion` | [`Int!`](#int) | Current lock version of the work item containing the task in the description. |
| <a id="workitemconverttaskinputtitle"></a>`title` | [`String!`](#string) | Full string of the task to be replaced. New title for the created work item. |
| <a id="workitemconverttaskinputworkitemtypeid"></a>`workItemTypeId` | [`WorkItemsTypeID!`](#workitemstypeid) | Global ID of the work item type used to create the new work item. |
### `WorkItemDeletedTaskInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemdeletedtaskinputid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the task referenced in the work item's description. |
| <a id="workitemdeletedtaskinputlinenumberend"></a>`lineNumberEnd` | [`Int!`](#int) | Last line in the Markdown source that defines the list item task. |
| <a id="workitemdeletedtaskinputlinenumberstart"></a>`lineNumberStart` | [`Int!`](#int) | First line in the Markdown source that defines the list item task. |

View File

@ -202,7 +202,7 @@ The [`custom_hooks_dir`](https://docs.gitlab.com/ee/administration/server_hooks.
### Permissions change for downloading Composer dependencies
WARNING:
This feature will be changed or removed in 15.0
This feature will be changed or removed in 14.10
as a [breaking change](https://docs.gitlab.com/ee/development/contributing/#breaking-changes).
Before updating GitLab, review the details carefully to determine if you need to make any
changes to your code, settings, or workflow.
@ -211,7 +211,7 @@ The GitLab Composer repository can be used to push, search, fetch metadata about
Downloading Composer dependencies without authentication is deprecated in GitLab 14.9, and will be removed in GitLab 15.0. Starting with GitLab 15.0, you must authenticate to download Composer dependencies.
**Planned removal milestone: 15.0 (2022-05-22)**
**Planned removal milestone: 14.10 (2022-04-22)**
### htpasswd Authentication for the Container Registry

View File

@ -173,6 +173,20 @@ The new security approvals feature is similar to vulnerability check. For exampl
- A two-step approval process can be enforced for any desired changes to security approval rules.
- A single set of security policies can be applied to multiple development projects to allow for ease in maintaining a single, centralized ruleset.
## 14.10
### Permissions change for downloading Composer dependencies
WARNING:
This feature was changed or removed in 14.10
as a [breaking change](https://docs.gitlab.com/ee/development/contributing/#breaking-changes).
Before updating GitLab, review the details carefully to determine if you need to make any
changes to your code, settings, or workflow.
The GitLab Composer repository can be used to push, search, fetch metadata about, and download PHP dependencies. All these actions require authentication, except for downloading dependencies.
Downloading Composer dependencies without authentication is deprecated in GitLab 14.9, and will be removed in GitLab 15.0. Starting with GitLab 15.0, you must authenticate to download Composer dependencies.
## 14.9
### Integrated error tracking disabled by default

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -13,7 +13,7 @@ Value stream analytics provides metrics about each stage of your software develo
A **value stream** is the entire work process that delivers value to customers. For example,
the [DevOps lifecycle](https://about.gitlab.com/stages-devops-lifecycle/) is a value stream that starts
with the "manage" and ends with the "protect" stage.
with the "manage" stage and ends with the "protect" stage.
Use value stream analytics to identify:
@ -34,7 +34,7 @@ Value stream analytics is also available for [projects](../../analytics/value_st
Prerequisite:
- You must have at least the Reporter role to view value stream analytics for groups.
- You must create a [custom value stream](#custom-value-streams). Value stream analytics only shows custom value streams created for your group.
- You must create a [custom value stream](#create-a-value-stream-with-gitlab-default-stages). Value stream analytics only shows custom value streams created for your group.
To view value stream analytics for your group:
@ -95,7 +95,10 @@ To view the median time spent in each stage by a group:
Value stream analytics shows the lead time and cycle time for issues in your groups:
- Lead time: Median time from when the issue was created to when it was closed.
- Cycle time: Median time from first commit to issue closed. GitLab measures cycle time from the earliest commit of a [linked issue's merge request](../../project/issues/crosslinking_issues.md#from-commit-messages) to when that issue is closed. The cycle time approach underestimates the lead time because merge request creation is always later than commit time.
- Cycle time: Median time from first commit to issue closed. GitLab measures cycle time from the earliest
commit of a [linked issue's merge request](../../project/issues/crosslinking_issues.md#from-commit-messages)
to when that issue is closed. The cycle time approach underestimates the lead time because merge request creation
is always later than commit time.
To view the lead time and cycle time for issues:
@ -185,7 +188,7 @@ To preview this functionality, you can use the **Filter by stop date** toggle to
If you turn on the **Filter by stop date** toggle, the results show items with a stop event within the date range. When this function is enabled, it may take up to 10 minutes for results to show due to data aggregation. There are occasions when it may take longer than 10 minutes for results to display:
- If this is the first time you are viewing value stream analytics and have not yet [created a value stream](#create-a-value-stream).
- If this is the first time you are viewing value stream analytics and have not yet [created a value stream](#create-a-value-stream-with-gitlab-default-stages).
- If the group hierarchy has been re-arranged.
- If there have been bulk updates on issues and merge requests.
@ -265,80 +268,58 @@ These patterns are not case-sensitive.
You can change the name of a project environment in your GitLab CI/CD configuration.
## Custom value streams
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12196) in GitLab 12.9.
Use custom value streams to create custom stages that align with your own development processes,
and hide default stages. The dashboard depicts stages as a horizontal process
flow.
### Create a value stream
## Create a value stream with GitLab default stages
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/221202) in GitLab 13.3
A default value stream is readily available for each group. You can create additional value streams
based on the different areas of work that you would like to measure.
Once created, a new value stream includes the stages that follow
[GitLab workflow](../../../topics/gitlab_flow.md)
best practices. You can customize this flow by adding, hiding or re-ordering stages.
To create a value stream:
When you create a value stream, you can use GitLab default stages and hide or re-order them to customize. You can also
create custom stages in addition to those provided in the default template.
1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Analytics > Value Stream**.
1. If this is the first time you are creating a value stream, select **Create custom value stream**. Otherwise, in the top right, select the dropdown list and then **Create new Value Stream**.
1. Enter a name for the new Value Stream.
- You can [customize the stages](#create-a-value-stream-with-stages).
1. Select **Create Value Stream**.
![New value stream](img/new_value_stream_v13_12.png "Creating a new value stream")
1. Select **Create new Value Stream**.
1. Enter a name for the value stream.
1. Select **Create from default template**.
1. Customize the default stages:
- To re-oder stages, select the up or down arrows.
- To hide a stage, select **Hide** ({**eye-slash**}).
1. To add a custom stage, select **Add another stage**.
- Enter a name for the stage.
- Select a **Start event** and a **Stop event**.
1. Select **Create value stream**.
NOTE:
If you have recently upgraded to GitLab Premium, it can take up to 30 minutes for data to collect and display.
### Create a value stream with stages
## Create a value stream with custom stages
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50229) in GitLab 13.7.
> - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55572) in GitLab 13.10.
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/294190) in GitLab 13.11.
You can create value streams with stages, starting with a default or a blank template. You can
add stages as desired.
To create a value stream with stages:
When you create a value stream, you can create and add custom stages that align with your own development workflows.
1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Analytics > Value Stream**.
1. If this is the first time you are creating a value stream, select **Create custom value stream**. Otherwise, in the top right, select the dropdown list and then **Create new Value Stream**.
1. Select either **Create from default template** or **Create from no template**.
- You can hide or re-order default stages in the value stream.
1. Select **Create value stream**.
1. For each stage:
- Enter a name for the stage.
- Select a **Start event** and a **Stop event**.
1. To add another stage, select **Add another stage**.
1. To re-order the stages, select the up or down arrows.
1. Select **Create value stream**.
![Default stage actions](img/vsa_default_stage_v13_10.png "Default stage actions")
### Label-based stages for custom value streams
- You can add new stages by selecting **Add another stage**.
- You can select the name and start and end events for the stage.
![Custom stage actions](img/vsa_custom_stage_v13_10.png "Custom stage actions")
1. Select **Create Value Stream**.
### Label-based stages
The pre-defined start and end events can cover many use cases involving both issues and merge requests.
In more complex workflows, use stages based on group labels. These events are based on
added or removed labels. In particular, [scoped labels](../../project/labels.md#scoped-labels)
are useful for complex workflows.
In this example, we'd like to measure times for deployment from a staging environment to production. The workflow is the following:
To measure complex workflows, you can use [scoped labels](../../project/labels.md#scoped-labels). For example, to measure deployment
time from a staging environment to production, you could use the following labels:
- When the code is deployed to staging, the `workflow::staging` label is added to the merge request.
- When the code is deployed to production, the `workflow::production` label is added to the merge request.
![Label-based value stream analytics stage](img/vsa_label_based_stage_v14_0.png "Creating a label-based value stream analytics stage")
### Edit a value stream
## Edit a value stream
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/267537) in GitLab 13.10.
@ -346,19 +327,18 @@ After you create a value stream, you can customize it to suit your purposes. To
1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Analytics > Value Stream**.
1. In the top right, select the dropdown list and then select the relevant value stream.
1. In the top right, select the dropdown list, and select a value stream.
1. Next to the value stream dropdown list, select **Edit**.
The edit form is populated with the value stream details.
1. Optional:
- Rename the value stream.
- Hide or re-order default stages.
- Remove existing custom stages.
- Add new stages by selecting the 'Add another stage' button
- To add new stages, select **Add another stage**.
- Select the start and end events for the stage.
1. Optional. To undo any modifications, select **Restore value stream defaults**.
1. Select **Save Value Stream**.
### Delete a value stream
## Delete a value stream
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/221205) in GitLab 13.4.

View File

@ -0,0 +1,5 @@
This directory contains the GitLab Flavored Markdown (GLFM) specification.
See the GitLab Flavored Markdown Specification Guide developer documentation for more information:
https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#specification-files

View File

@ -0,0 +1,23 @@
# First GitLab-Specific Section with Examples
## Strong but with two asterisks
```````````````````````````````` example
**bold**
.
<p><strong>bold</strong></p>
````````````````````````````````
# Second GitLab-Specific Section with Examples
## Strong but with HTML
```````````````````````````````` example
<strong>
bold
</strong>
.
<p><strong>
bold
</strong></p>
````````````````````````````````

View File

@ -0,0 +1,3 @@
# Introduction
TODO: Write a GitLab-specific version of the GitHub Flavored Markdown intro section.

File diff suppressed because it is too large Load Diff

View File

@ -212,7 +212,7 @@ module API
requires :id, type: Integer, desc: %q(Job's ID)
optional :token, type: String, desc: %q(Job's authentication token)
end
patch '/:id/trace', urgency: :default, feature_category: :continuous_integration do
patch '/:id/trace', urgency: :low, feature_category: :continuous_integration do
job = authenticate_job!(heartbeat_runner: true)
error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range')

View File

@ -23718,6 +23718,9 @@ msgstr ""
msgid "Maximum number of unique IP addresses per user."
msgstr ""
msgid "Maximum of 255 characters"
msgstr ""
msgid "Maximum page reached"
msgstr ""
@ -35593,6 +35596,9 @@ msgstr ""
msgid "Snippets|Optionally add a description about what your snippet does or how to use it…"
msgstr ""
msgid "Snippets|Snippets can't contain empty files. Ensure all files have content, or delete them."
msgstr ""
msgid "Snowplow"
msgstr ""

View File

@ -57,7 +57,7 @@
"@gitlab/at.js": "1.5.7",
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/svgs": "2.11.0",
"@gitlab/ui": "39.4.0",
"@gitlab/ui": "39.6.0",
"@gitlab/visual-review-tools": "1.7.0",
"@rails/actioncable": "6.1.4-7",
"@rails/ujs": "6.1.4-7",

View File

@ -0,0 +1,5 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require_relative '../lib/glfm/update_specification'
Glfm::UpdateSpecification.new.process

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
module Glfm
module Constants
# Root dir containing all specification files
specification_path = Pathname.new(File.expand_path("../../../glfm_specification", __dir__))
# GitHub Flavored Markdown specification file
GHFM_SPEC_TXT_URI = 'https://raw.githubusercontent.com/github/cmark-gfm/master/test/spec.txt'
GHFM_SPEC_VERSION = '0.29'
GHFM_SPEC_TXT_FILENAME = "ghfm_spec_v_#{GHFM_SPEC_VERSION}.txt"
GHFM_SPEC_TXT_PATH = specification_path.join('input/github_flavored_markdown', GHFM_SPEC_TXT_FILENAME)
# GitLab Flavored Markdown specification files
specification_input_glfm_path = specification_path.join('input/gitlab_flavored_markdown')
GLFM_INTRO_TXT_PATH = specification_input_glfm_path.join('glfm_intro.txt')
GLFM_EXAMPLES_TXT_PATH = specification_input_glfm_path.join('glfm_canonical_examples.txt')
GLFM_SPEC_TXT_PATH = specification_path.join('output/spec.txt')
# Other constants used for processing files
GLFM_SPEC_TXT_HEADER = <<~GLFM_SPEC_TXT_HEADER
---
title: GitLab Flavored Markdown (GLFM) Spec
version: alpha
...
GLFM_SPEC_TXT_HEADER
INTRODUCTION_HEADER_LINE_TEXT = /\A# Introduction\Z/.freeze
END_TESTS_COMMENT_LINE_TEXT = /\A<!-- END TESTS -->\Z/.freeze
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
require 'fileutils'
module Glfm
module Shared
def write_file(file_path, file_content_string)
FileUtils.mkdir_p(File.dirname(file_path))
# NOTE: We don't use the block form of File.open because we want to be able to easily
# mock it for testing.
io = File.open(file_path, 'w')
io.binmode
io.write(file_content_string)
# NOTE: We are using #fsync + #close_write instead of just #close`, in order to unit test
# with a real StringIO and not just a mock object.
io.fsync
io.close_write
end
# All script output goes through this method. This makes it easy to mock in order to prevent
# output from being printed to the console during tests. We don't want to directly mock
# Kernel#puts, because that could interfere or cause spurious test failures when #puts is used
# for debugging. And for some reason RuboCop says `rubocop:disable Rails/Output` would be
# redundant here, so not including it.
def output(string)
puts string
end
end
end

View File

@ -0,0 +1,105 @@
# frozen_string_literal: true
require 'fileutils'
require 'open-uri'
require 'pathname'
require_relative 'constants'
require_relative 'shared'
module Glfm
class UpdateSpecification
include Constants
include Shared
def process
output('Updating specification...')
ghfm_spec_txt_lines = download_and_write_ghfm_spec_txt
glfm_spec_txt_string = build_glfm_spec_txt(ghfm_spec_txt_lines)
write_glfm_spec_txt(glfm_spec_txt_string)
end
private
def download_and_write_ghfm_spec_txt
output("Downloading #{GHFM_SPEC_TXT_URI}...")
ghfm_spec_txt_uri_io = URI.open(GHFM_SPEC_TXT_URI)
# Read IO stream into an array of lines for easy processing later
ghfm_spec_txt_lines = ghfm_spec_txt_uri_io.readlines
raise "Unable to read lines from #{GHFM_SPEC_TXT_URI}" if ghfm_spec_txt_lines.empty?
# Make sure the GHFM spec version has not changed
validate_expected_spec_version!(ghfm_spec_txt_lines[2])
# Reset IO stream and re-read into a single string for easy writing
# noinspection RubyNilAnalysis
ghfm_spec_txt_uri_io.seek(0)
ghfm_spec_txt_string = ghfm_spec_txt_uri_io.read
raise "Unable to read string from #{GHFM_SPEC_TXT_URI}" unless ghfm_spec_txt_string
output("Writing #{GHFM_SPEC_TXT_PATH}...")
GHFM_SPEC_TXT_PATH.dirname.mkpath
write_file(GHFM_SPEC_TXT_PATH, ghfm_spec_txt_string)
ghfm_spec_txt_lines
end
def validate_expected_spec_version!(version_line)
return if version_line =~ /\Aversion: #{GHFM_SPEC_VERSION}\Z/o
raise "GitHub Flavored Markdown spec.txt version mismatch! " \
"Expected 'version: #{GHFM_SPEC_VERSION}', got '#{version_line}'"
end
def build_glfm_spec_txt(ghfm_spec_txt_lines)
glfm_spec_txt_lines = ghfm_spec_txt_lines.dup
replace_header(glfm_spec_txt_lines)
replace_intro_section(glfm_spec_txt_lines)
insert_examples_txt(glfm_spec_txt_lines)
glfm_spec_txt_lines.join('')
end
def replace_header(spec_txt_lines)
spec_txt_lines[0, spec_txt_lines.index("...\n") + 1] = GLFM_SPEC_TXT_HEADER
end
def replace_intro_section(spec_txt_lines)
glfm_intro_txt_lines = File.open(GLFM_INTRO_TXT_PATH).readlines
raise "Unable to read lines from #{GLFM_INTRO_TXT_PATH}" if glfm_intro_txt_lines.empty?
ghfm_intro_header_begin_index = spec_txt_lines.index do |line|
line =~ INTRODUCTION_HEADER_LINE_TEXT
end
raise "Unable to locate introduction header line in #{GHFM_SPEC_TXT_PATH}" if ghfm_intro_header_begin_index.nil?
# Find the index of the next header after the introduction header, starting from the index
# of the introduction header this is the length of the intro section
ghfm_intro_section_length = spec_txt_lines[ghfm_intro_header_begin_index + 1..].index do |line|
line.start_with?('# ')
end
# Replace the intro section with the GitLab flavored Markdown intro section
spec_txt_lines[ghfm_intro_header_begin_index, ghfm_intro_section_length] = glfm_intro_txt_lines
end
def insert_examples_txt(spec_txt_lines)
glfm_examples_txt_lines = File.open(GLFM_EXAMPLES_TXT_PATH).readlines
raise "Unable to read lines from #{GLFM_EXAMPLES_TXT_PATH}" if glfm_examples_txt_lines.empty?
ghfm_end_tests_comment_index = spec_txt_lines.index do |line|
line =~ END_TESTS_COMMENT_LINE_TEXT
end
raise "Unable to locate 'END TESTS' comment line in #{GHFM_SPEC_TXT_PATH}" if ghfm_end_tests_comment_index.nil?
# Insert the GLFM examples before the 'END TESTS' comment line
spec_txt_lines[ghfm_end_tests_comment_index - 1] = ["\n", glfm_examples_txt_lines, "\n"].flatten
spec_txt_lines
end
def write_glfm_spec_txt(glfm_spec_txt_string)
output("Writing #{GLFM_SPEC_TXT_PATH}...")
FileUtils.mkdir_p(Pathname.new(GLFM_SPEC_TXT_PATH).dirname)
write_file(GLFM_SPEC_TXT_PATH, glfm_spec_txt_string)
end
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Merge request > User opens checkout branch modal', :js do
include ProjectForksHelper
let(:project) { create(:project, :public, :repository) }
let(:user) { project.creator }
before do
project.add_maintainer(user)
sign_in(user)
end
describe 'for fork' do
let(:author) { create(:user) }
let(:source_project) { fork_project(project, author, repository: true) }
let(:merge_request) do
create(:merge_request,
source_project: source_project,
target_project: project,
source_branch: 'fix',
target_branch: 'master',
author: author,
allow_collaboration: true)
end
it 'shows instructions' do
visit project_merge_request_path(project, merge_request)
click_button 'Code'
click_button 'Check out branch'
expect(page).to have_content(source_project.http_url_to_repo)
end
end
end

View File

@ -85,13 +85,4 @@ RSpec.describe 'Projects > Snippets > Create Snippet', :js do
expect(page).to have_content('New Snippet')
end
end
it 'does not allow submitting the form without title and content' do
snippet_fill_in_title(title)
expect(page).not_to have_button('Create snippet')
snippet_fill_in_form(title: title, content: file_content)
expect(page).to have_button('Create snippet')
end
end

View File

@ -109,13 +109,22 @@ RSpec.describe 'User creates snippet', :js do
end
end
it 'validation fails for the first time' do
fill_in snippet_title_field, with: title
it 'shows validation errors' do
title_validation_message = _("This field is required.")
files_validation_message = _("Snippets can't contain empty files. Ensure all files have content, or delete them.")
expect(page).not_to have_button('Create snippet')
click_button('Create snippet')
expect(page).to have_content(title_validation_message)
expect(page).to have_content(files_validation_message)
snippet_fill_in_title(title)
expect(page).not_to have_content(title_validation_message)
snippet_fill_in_form(title: title, content: file_content)
expect(page).to have_button('Create snippet')
expect(page).not_to have_content(files_validation_message)
end
it 'previews a snippet with file' do

View File

@ -465,6 +465,14 @@ describe('Issuable output', () => {
expect(findStickyHeader().text()).toContain('Sticky header title');
});
it('shows with title for an epic', async () => {
wrapper.setProps({ issuableType: 'epic' });
await nextTick();
expect(findStickyHeader().text()).toContain('Sticky header title');
});
it.each`
title | state
${'shows with Open when status is opened'} | ${IssuableStatus.Open}

View File

@ -1,5 +1,4 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { GlFormGroup, GlLoadingIcon } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import { merge } from 'lodash';
@ -7,6 +6,7 @@ import VueApollo, { ApolloMutation } from 'vue-apollo';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
import createFlash from '~/flash';
import * as urlUtils from '~/lib/utils/url_utility';
@ -22,7 +22,6 @@ import {
import CreateSnippetMutation from '~/snippets/mutations/create_snippet.mutation.graphql';
import UpdateSnippetMutation from '~/snippets/mutations/update_snippet.mutation.graphql';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import TitleField from '~/vue_shared/components/form/title.vue';
import { testEntries, createGQLSnippetsQueryResponse, createGQLSnippet } from '../test_utils';
jest.mock('~/flash');
@ -112,19 +111,19 @@ describe('Snippet Edit app', () => {
gon.relative_url_root = originalRelativeUrlRoot;
});
const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit);
const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]');
const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled'));
const clickSubmitBtn = () => wrapper.find('[data-testid="snippet-edit-form"]').trigger('submit');
const findBlobActions = () => wrapper.findComponent(SnippetBlobActionsEdit);
const findCancelButton = () => wrapper.findByTestId('snippet-cancel-btn');
const clickSubmitBtn = () => wrapper.findByTestId('snippet-edit-form').trigger('submit');
const triggerBlobActions = (actions) => findBlobActions().vm.$emit('actions', actions);
const setUploadFilesHtml = (paths) => {
wrapper.vm.$el.innerHTML = paths
.map((path) => `<input name="files[]" value="${path}">`)
.join('');
};
const setTitle = (val) => wrapper.find(TitleField).vm.$emit('input', val);
const setDescription = (val) => wrapper.find(SnippetDescriptionEdit).vm.$emit('input', val);
const setTitle = (val) => wrapper.findByTestId('snippet-title-input').vm.$emit('input', val);
const setDescription = (val) =>
wrapper.findComponent(SnippetDescriptionEdit).vm.$emit('input', val);
const createComponent = ({ props = {}, selectedLevel = SNIPPET_VISIBILITY_PRIVATE } = {}) => {
if (wrapper) {
@ -139,7 +138,7 @@ describe('Snippet Edit app', () => {
];
const apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMount(SnippetEditApp, {
wrapper = shallowMountExtended(SnippetEditApp, {
apolloProvider,
stubs: {
ApolloMutation,
@ -177,7 +176,7 @@ describe('Snippet Edit app', () => {
it('renders loader', () => {
createComponent();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
});
@ -193,10 +192,10 @@ describe('Snippet Edit app', () => {
});
it('should render components', () => {
expect(wrapper.find(TitleField).exists()).toBe(true);
expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true);
expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true);
expect(wrapper.find(FormFooterActions).exists()).toBe(true);
expect(wrapper.findComponent(GlFormGroup).attributes('label')).toEqual('Title');
expect(wrapper.findComponent(SnippetDescriptionEdit).exists()).toBe(true);
expect(wrapper.findComponent(SnippetVisibilityEdit).exists()).toBe(true);
expect(wrapper.findComponent(FormFooterActions).exists()).toBe(true);
expect(findBlobActions().exists()).toBe(true);
});
@ -207,25 +206,34 @@ describe('Snippet Edit app', () => {
describe('default', () => {
it.each`
title | actions | shouldDisable
${''} | ${[]} | ${true}
${''} | ${[TEST_ACTIONS.VALID]} | ${true}
${'foo'} | ${[]} | ${false}
${'foo'} | ${[TEST_ACTIONS.VALID]} | ${false}
${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_CONTENT]} | ${true}
${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_PATH]} | ${false}
title | actions | titleHasErrors | blobActionsHasErrors
${''} | ${[]} | ${true} | ${false}
${''} | ${[TEST_ACTIONS.VALID]} | ${true} | ${false}
${'foo'} | ${[]} | ${false} | ${false}
${'foo'} | ${[TEST_ACTIONS.VALID]} | ${false} | ${false}
${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_CONTENT]} | ${false} | ${true}
${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_PATH]} | ${false} | ${false}
`(
'should handle submit disable (title="$title", actions="$actions", shouldDisable="$shouldDisable")',
async ({ title, actions, shouldDisable }) => {
'validates correctly (title="$title", actions="$actions", titleHasErrors="$titleHasErrors", blobActionsHasErrors="$blobActionsHasErrors")',
async ({ title, actions, titleHasErrors, blobActionsHasErrors }) => {
getSpy.mockResolvedValue(createQueryResponse({ title }));
await createComponentAndLoad();
triggerBlobActions(actions);
clickSubmitBtn();
await nextTick();
expect(hasDisabledSubmit()).toBe(shouldDisable);
expect(wrapper.findComponent(GlFormGroup).exists()).toBe(true);
expect(Boolean(wrapper.findComponent(GlFormGroup).attributes('state'))).toEqual(
!titleHasErrors,
);
expect(wrapper.find(SnippetBlobActionsEdit).props('isValid')).toEqual(
!blobActionsHasErrors,
);
},
);
@ -262,35 +270,64 @@ describe('Snippet Edit app', () => {
);
describe('form submission handling', () => {
it.each`
snippetGid | projectPath | uploadedFiles | input | mutationType
${''} | ${'project/path'} | ${[]} | ${{ ...getApiData(), projectPath: 'project/path', uploadedFiles: [] }} | ${'createSnippet'}
${''} | ${''} | ${[]} | ${{ ...getApiData(), projectPath: '', uploadedFiles: [] }} | ${'createSnippet'}
${''} | ${''} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData(), projectPath: '', uploadedFiles: TEST_UPLOADED_FILES }} | ${'createSnippet'}
${TEST_SNIPPET_GID} | ${'project/path'} | ${[]} | ${getApiData(createSnippet())} | ${'updateSnippet'}
${TEST_SNIPPET_GID} | ${''} | ${[]} | ${getApiData(createSnippet())} | ${'updateSnippet'}
`(
'should submit mutation $mutationType (snippetGid=$snippetGid, projectPath=$projectPath, uploadedFiles=$uploadedFiles)',
async ({ snippetGid, projectPath, uploadedFiles, mutationType, input }) => {
await createComponentAndLoad({
props: {
snippetGid,
projectPath,
},
});
describe('when creating a new snippet', () => {
it.each`
projectPath | uploadedFiles | input
${''} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData({ title: 'Title' }), projectPath: '', uploadedFiles: TEST_UPLOADED_FILES }}
${'project/path'} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData({ title: 'Title' }), projectPath: 'project/path', uploadedFiles: TEST_UPLOADED_FILES }}
`(
'should submit a createSnippet mutation (projectPath=$projectPath, uploadedFiles=$uploadedFiles)',
async ({ projectPath, uploadedFiles, input }) => {
await createComponentAndLoad({
props: {
snippetGid: '',
projectPath,
},
});
setUploadFilesHtml(uploadedFiles);
setTitle(input.title);
setUploadFilesHtml(uploadedFiles);
await nextTick();
await nextTick();
clickSubmitBtn();
clickSubmitBtn();
expect(mutateSpy).toHaveBeenCalledTimes(1);
expect(mutateSpy).toHaveBeenCalledWith(mutationType, {
input,
});
},
);
expect(mutateSpy).toHaveBeenCalledTimes(1);
expect(mutateSpy).toHaveBeenCalledWith('createSnippet', {
input,
});
},
);
});
describe('when updating a snippet', () => {
it.each`
projectPath | uploadedFiles | input
${''} | ${[]} | ${getApiData(createSnippet())}
${'project/path'} | ${[]} | ${getApiData(createSnippet())}
`(
'should submit an updateSnippet mutation (projectPath=$projectPath, uploadedFiles=$uploadedFiles)',
async ({ projectPath, uploadedFiles, input }) => {
await createComponentAndLoad({
props: {
snippetGid: TEST_SNIPPET_GID,
projectPath,
},
});
setUploadFilesHtml(uploadedFiles);
await nextTick();
clickSubmitBtn();
expect(mutateSpy).toHaveBeenCalledTimes(1);
expect(mutateSpy).toHaveBeenCalledWith('updateSnippet', {
input,
});
},
);
});
it('should redirect to snippet view on successful mutation', async () => {
await createComponentAndSubmit();
@ -298,30 +335,55 @@ describe('Snippet Edit app', () => {
expect(urlUtils.redirectTo).toHaveBeenCalledWith(TEST_WEB_URL);
});
it.each`
snippetGid | projectPath | mutationRes | expectMessage
${''} | ${'project/path'} | ${createMutationResponseWithErrors('createSnippet')} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`}
${''} | ${''} | ${createMutationResponseWithErrors('createSnippet')} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`}
${TEST_SNIPPET_GID} | ${'project/path'} | ${createMutationResponseWithErrors('updateSnippet')} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`}
${TEST_SNIPPET_GID} | ${''} | ${createMutationResponseWithErrors('updateSnippet')} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`}
`(
'should flash error with (snippet=$snippetGid, projectPath=$projectPath)',
async ({ snippetGid, projectPath, mutationRes, expectMessage }) => {
mutateSpy.mockResolvedValue(mutationRes);
describe('when there are errors after creating a new snippet', () => {
it.each`
projectPath
${'project/path'}
${''}
`('should flash error (projectPath=$projectPath)', async ({ projectPath }) => {
mutateSpy.mockResolvedValue(createMutationResponseWithErrors('createSnippet'));
await createComponentAndSubmit({
props: {
projectPath,
snippetGid,
},
await createComponentAndLoad({
props: { projectPath, snippetGid: '' },
});
setTitle('Title');
clickSubmitBtn();
await waitForPromises();
expect(urlUtils.redirectTo).not.toHaveBeenCalled();
expect(createFlash).toHaveBeenCalledWith({
message: expectMessage,
message: `Can't create snippet: ${TEST_MUTATION_ERROR}`,
});
},
);
});
});
describe('when there are errors after updating a snippet', () => {
it.each`
projectPath
${'project/path'}
${''}
`(
'should flash error with (snippet=$snippetGid, projectPath=$projectPath)',
async ({ projectPath }) => {
mutateSpy.mockResolvedValue(createMutationResponseWithErrors('updateSnippet'));
await createComponentAndSubmit({
props: {
projectPath,
snippetGid: TEST_SNIPPET_GID,
},
});
expect(urlUtils.redirectTo).not.toHaveBeenCalled();
expect(createFlash).toHaveBeenCalledWith({
message: `Can't update snippet: ${TEST_MUTATION_ERROR}`,
});
},
);
});
describe('with apollo network error', () => {
beforeEach(async () => {
@ -382,6 +444,7 @@ describe('Snippet Edit app', () => {
false,
() => {
triggerBlobActions([testEntries.updated.diff]);
setTitle('test');
clickSubmitBtn();
},
],

View File

@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { times } from 'lodash';
import { nextTick } from 'vue';
import { GlFormGroup } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue';
import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue';
import {
@ -8,6 +9,7 @@ import {
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_MOVE,
} from '~/snippets/constants';
import { s__ } from '~/locale';
import { testEntries, createBlobFromTestEntry } from '../test_utils';
const TEST_BLOBS = [
@ -29,7 +31,7 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
});
};
const findLabel = () => wrapper.find('label');
const findLabel = () => wrapper.findComponent(GlFormGroup);
const findBlobEdits = () => wrapper.findAll(SnippetBlobEdit);
const findBlobsData = () =>
findBlobEdits().wrappers.map((x) => ({
@ -65,7 +67,7 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
});
it('renders label', () => {
expect(findLabel().text()).toBe('Files');
expect(findLabel().attributes('label')).toBe('Files');
});
it(`renders delete button (show=true)`, () => {
@ -280,4 +282,32 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
expect(findAddButton().props('disabled')).toBe(true);
});
});
describe('isValid prop', () => {
const validationMessage = s__(
"Snippets|Snippets can't contain empty files. Ensure all files have content, or delete them.",
);
describe('when not present', () => {
it('sets the label validation state to true', () => {
createComponent();
const label = findLabel();
expect(Boolean(label.attributes('state'))).toEqual(true);
expect(label.attributes('invalid-feedback')).toEqual(validationMessage);
});
});
describe('when present', () => {
it('sets the label validation state to the value', () => {
createComponent({ isValid: false });
const label = findLabel();
expect(Boolean(label.attributes('state'))).toEqual(false);
expect(label.attributes('invalid-feedback')).toEqual(validationMessage);
});
});
});
});

View File

@ -0,0 +1,93 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe "Delete a task in a work item's description" do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
let_it_be(:task) { create(:work_item, :task, project: project, author: developer) }
let_it_be(:work_item, refind: true) do
create(:work_item, project: project, description: "- [ ] #{task.to_reference}+", lock_version: 3)
end
before_all do
create(:issue_link, source_id: work_item.id, target_id: task.id)
end
let(:lock_version) { work_item.lock_version }
let(:input) do
{
'id' => work_item.to_global_id.to_s,
'lockVersion' => lock_version,
'taskData' => {
'id' => task.to_global_id.to_s,
'lineNumberStart' => 1,
'lineNumberEnd' => 1
}
}
end
let(:mutation) { graphql_mutation(:workItemDeleteTask, input) }
let(:mutation_response) { graphql_mutation_response(:work_item_delete_task) }
context 'the user is not allowed to update a work item' do
let(:current_user) { create(:user) }
it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user can update the description but not delete the task' do
let(:current_user) { create(:user).tap { |u| project.add_developer(u) } }
it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to remove a task' do
let(:current_user) { developer }
it 'removes the task from the work item' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to change(WorkItem, :count).by(-1).and(
change(IssueLink, :count).by(-1)
).and(
change(work_item, :description).from("- [ ] #{task.to_reference}+").to('')
)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['workItem']).to include('id' => work_item.to_global_id.to_s)
end
context 'when removing the task fails' do
let(:lock_version) { 2 }
it 'makes no changes to the DB and returns an error message' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to not_change(WorkItem, :count).and(
not_change(work_item, :description)
)
expect(mutation_response['errors']).to contain_exactly('Stale work item. Check lock version')
end
end
context 'when the work_items feature flag is disabled' do
before do
stub_feature_flags(work_items: false)
end
it 'does nothing and returns and error' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.to not_change(WorkItem, :count)
expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project')
end
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require_relative '../../../../scripts/lib/glfm/shared'
RSpec.describe Glfm::Shared do
describe '#output' do
# NOTE: The #output method is normally always mocked, to prevent output while the specs are
# running. However, in order to provide code coverage for the method, we have to invoke
# it at least once.
it 'has code coverage' do
clazz = Class.new do
include Glfm::Shared
end
instance = clazz.new
allow(instance).to receive(:puts)
instance.output('')
end
end
end

View File

@ -0,0 +1,173 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require_relative '../../../../scripts/lib/glfm/update_specification'
RSpec.describe Glfm::UpdateSpecification, '#process' do
subject { described_class.new }
let(:ghfm_spec_txt_uri) { described_class::GHFM_SPEC_TXT_URI }
let(:ghfm_spec_txt_uri_io) { StringIO.new(ghfm_spec_txt_contents) }
let(:ghfm_spec_txt_path) { described_class::GHFM_SPEC_TXT_PATH }
let(:ghfm_spec_txt_local_io) { StringIO.new }
let(:glfm_intro_txt_path) { described_class::GLFM_INTRO_TXT_PATH }
let(:glfm_intro_txt_io) { StringIO.new(glfm_intro_txt_contents) }
let(:glfm_examples_txt_path) { described_class::GLFM_EXAMPLES_TXT_PATH }
let(:glfm_examples_txt_io) { StringIO.new(glfm_examples_txt_contents) }
let(:glfm_spec_txt_path) { described_class::GLFM_SPEC_TXT_PATH }
let(:glfm_spec_txt_io) { StringIO.new }
let(:ghfm_spec_txt_contents) do
<<~GHFM_SPEC_TXT_CONTENTS
---
title: GitHub Flavored Markdown Spec
version: 0.29
date: '2019-04-06'
license: '[CC-BY-SA 4.0](http://creativecommons.org/licenses/by-sa/4.0/)'
...
# Introduction
## What is GitHub Flavored Markdown?
It's like GLFM, but with an H.
# Section with Examples
## Strong
```````````````````````````````` example
__bold__
.
<p><strong>bold</strong></p>
````````````````````````````````
End of last GitHub examples section.
<!-- END TESTS -->
# Appendix
Appendix text.
GHFM_SPEC_TXT_CONTENTS
end
let(:glfm_intro_txt_contents) do
<<~GLFM_INTRO_TXT_CONTENTS
# Introduction
## What is GitLab Flavored Markdown?
Intro text about GitLab Flavored Markdown.
GLFM_INTRO_TXT_CONTENTS
end
let(:glfm_examples_txt_contents) do
<<~GLFM_EXAMPLES_TXT_CONTENTS
# GitLab-Specific Section with Examples
Some examples.
GLFM_EXAMPLES_TXT_CONTENTS
end
before do
# We mock out the URI and local file IO objects with real StringIO, instead of just mock
# objects. This gives better and more realistic coverage, while still avoiding
# actual network and filesystem I/O during the spec run.
allow(URI).to receive(:open).with(ghfm_spec_txt_uri) { ghfm_spec_txt_uri_io }
allow(File).to receive(:open).with(ghfm_spec_txt_path, 'w') { ghfm_spec_txt_local_io }
allow(File).to receive(:open).with(glfm_intro_txt_path) { glfm_intro_txt_io }
allow(File).to receive(:open).with(glfm_examples_txt_path) { glfm_examples_txt_io }
allow(File).to receive(:open).with(glfm_spec_txt_path, 'w') { glfm_spec_txt_io }
# Prevent console output when running tests
allow(subject).to receive(:output)
end
describe 'retrieving latest GHFM spec.txt' do
context 'with success' do
it 'downloads and saves' do
subject.process
expect(reread_io(ghfm_spec_txt_local_io)).to eq(ghfm_spec_txt_contents)
end
end
context 'with error handling' do
context 'with a version mismatch' do
let(:ghfm_spec_txt_contents) do
<<~GHFM_SPEC_TXT_CONTENTS
---
title: GitHub Flavored Markdown Spec
version: 0.30
...
GHFM_SPEC_TXT_CONTENTS
end
it 'raises an error' do
expect { subject.process }.to raise_error /version mismatch.*expected.*29.*got.*30/i
end
end
context 'with a failed read of file lines' do
let(:ghfm_spec_txt_contents) { '' }
it 'raises an error if lines cannot be read' do
expect { subject.process }.to raise_error /unable to read lines/i
end
end
context 'with a failed re-read of file string' do
before do
allow(ghfm_spec_txt_uri_io).to receive(:read).and_return(nil)
end
it 'raises an error if file is blank' do
expect { subject.process }.to raise_error /unable to read string/i
end
end
end
end
describe 'writing GLFM spec.txt' do
let(:glfm_contents) { reread_io(glfm_spec_txt_io) }
before do
subject.process
end
it 'replaces the header text with the GitLab version' do
expect(glfm_contents).not_to match(/GitHub Flavored Markdown Spec/m)
expect(glfm_contents).not_to match(/^version: \d\.\d/m)
expect(glfm_contents).not_to match(/^date: /m)
expect(glfm_contents).not_to match(/^license: /m)
expect(glfm_contents).to match(/#{Regexp.escape(described_class::GLFM_SPEC_TXT_HEADER)}\n/m)
end
it 'replaces the intro section with the GitLab version' do
expect(glfm_contents).not_to match(/What is GitHub Flavored Markdown/m)
expect(glfm_contents).to match(/#{Regexp.escape(glfm_intro_txt_contents)}/m)
end
it 'inserts the GitLab examples sections before the appendix section' do
expected = <<~GHFM_SPEC_TXT_CONTENTS
End of last GitHub examples section.
# GitLab-Specific Section with Examples
Some examples.
<!-- END TESTS -->
# Appendix
GHFM_SPEC_TXT_CONTENTS
expect(glfm_contents).to match(/#{Regexp.escape(expected)}/m)
end
end
def reread_io(io)
# Reset the io StringIO to the beginning position of the buffer
io.seek(0)
io.read
end
end

View File

@ -0,0 +1,88 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WorkItems::DeleteTaskService do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
let_it_be_with_refind(:task) { create(:work_item, project: project, author: developer) }
let_it_be_with_refind(:list_work_item) do
create(:work_item, project: project, description: "- [ ] #{task.to_reference}+")
end
let(:current_user) { developer }
let(:line_number_start) { 1 }
let(:params) do
{
line_number_start: line_number_start,
line_number_end: 1,
task: task
}
end
before_all do
create(:issue_link, source_id: list_work_item.id, target_id: task.id)
end
shared_examples 'failing WorkItems::DeleteTaskService' do |error_message|
it { is_expected.to be_error }
it 'does not remove work item or issue links' do
expect do
service_result
list_work_item.reload
end.to not_change(WorkItem, :count).and(
not_change(IssueLink, :count)
).and(
not_change(list_work_item, :description)
)
end
it 'returns an error message' do
expect(service_result.errors).to contain_exactly(error_message)
end
end
describe '#execute' do
subject(:service_result) do
described_class.new(
work_item: list_work_item,
current_user: current_user,
lock_version: list_work_item.lock_version,
task_params: params
).execute
end
context 'when work item params are valid' do
it { is_expected.to be_success }
it 'deletes the work item and the related issue link' do
expect do
service_result
end.to change(WorkItem, :count).by(-1).and(
change(IssueLink, :count).by(-1)
)
end
it 'removes the task list item with the work item reference' do
expect do
service_result
end.to change(list_work_item, :description).from(list_work_item.description).to('')
end
end
context 'when first operation fails' do
let(:line_number_start) { -1 }
it_behaves_like 'failing WorkItems::DeleteTaskService', 'line_number_start must be greater than 0'
end
context 'when last operation fails' do
let_it_be(:non_member_user) { create(:user) }
let(:current_user) { non_member_user }
it_behaves_like 'failing WorkItems::DeleteTaskService', 'User not authorized to delete work item'
end
end
end

View File

@ -0,0 +1,151 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WorkItems::TaskListReferenceRemovalService do
let_it_be(:developer) { create(:user) }
let_it_be(:project) { create(:project, :repository).tap { |project| project.add_developer(developer) } }
let_it_be(:task) { create(:work_item, project: project) }
let_it_be(:single_line_work_item, refind: true) do
create(:work_item, project: project, description: "- [ ] #{task.to_reference}+ single line")
end
let_it_be(:multiple_line_work_item, refind: true) do
create(
:work_item,
project: project,
description: <<~MARKDOWN
Any text
* [ ] Item to be converted
#{task.to_reference}+ second line
third line
* [x] task
More text
MARKDOWN
)
end
let(:line_number_start) { 3 }
let(:line_number_end) { 5 }
let(:work_item) { multiple_line_work_item }
let(:lock_version) { work_item.lock_version }
shared_examples 'successful work item task reference removal service' do |expected_description|
it { is_expected.to be_success }
it 'removes the task list item containing the task reference' do
expect do
result
end.to change(work_item, :description).from(work_item.description).to(expected_description)
end
it 'creates system notes' do
expect do
result
end.to change(Note, :count).by(1)
expect(Note.last.note).to include('changed the description')
end
end
shared_examples 'failing work item task reference removal service' do |error_message|
it { is_expected.to be_error }
it 'does not change the work item description' do
expect do
result
work_item.reload
end.to not_change(work_item, :description)
end
it 'returns an error message' do
expect(result.errors).to contain_exactly(error_message)
end
end
describe '#execute' do
subject(:result) do
described_class.new(
work_item: work_item,
task: task,
line_number_start: line_number_start,
line_number_end: line_number_end,
lock_version: lock_version,
current_user: developer
).execute
end
context 'when task mardown spans a single line' do
let(:line_number_start) { 1 }
let(:line_number_end) { 1 }
let(:work_item) { single_line_work_item }
it_behaves_like 'successful work item task reference removal service', ''
context 'when description does not contain a task' do
let_it_be(:no_matching_work_item) { create(:work_item, project: project, description: 'no matching task') }
let(:work_item) { no_matching_work_item }
it_behaves_like 'failing work item task reference removal service', 'Unable to detect a task on lines 1-1'
end
context 'when description reference does not exactly match the task reference' do
before do
work_item.update!(description: work_item.description.gsub(task.to_reference, "#{task.to_reference}200"))
end
it_behaves_like 'failing work item task reference removal service', 'Unable to detect a task on lines 1-1'
end
end
context 'when task mardown spans multiple lines' do
it_behaves_like 'successful work item task reference removal service', "Any text\n\n* [x] task\n\nMore text"
end
context 'when updating the work item fails' do
before do
work_item.title = nil
end
it_behaves_like 'failing work item task reference removal service', "Title can't be blank"
end
context 'when description is empty' do
let_it_be(:empty_work_item) { create(:work_item, project: project, description: '') }
let(:work_item) { empty_work_item }
it_behaves_like 'failing work item task reference removal service', "Work item description can't be blank"
end
context 'when line_number_start is lower than 1' do
let(:line_number_start) { 0 }
it_behaves_like 'failing work item task reference removal service', 'line_number_start must be greater than 0'
end
context 'when line_number_end is lower than line_number_start' do
let(:line_number_end) { line_number_start - 1 }
it_behaves_like 'failing work item task reference removal service',
'line_number_end must be greater or equal to line_number_start'
end
context 'when lock_version is older than current' do
let(:lock_version) { work_item.lock_version - 1 }
it_behaves_like 'failing work item task reference removal service', 'Stale work item. Check lock version'
end
context 'when work item is stale before updating' do
it_behaves_like 'failing work item task reference removal service', 'Stale work item. Check lock version' do
before do
::WorkItem.where(id: work_item.id).update_all(lock_version: lock_version + 1)
end
end
end
end
end

View File

@ -67,14 +67,19 @@ module Spec
end
def snippet_fill_in_form(title: nil, content: nil, file_name: nil, description: nil, visibility: nil)
if content
snippet_fill_in_content(content)
# It takes some time after sending keys for the vue component to
# update so let Capybara wait for the content before proceeding
expect(page).to have_content(content)
end
snippet_fill_in_title(title) if title
snippet_fill_in_description(description) if description
snippet_fill_in_file_name(file_name) if file_name
snippet_fill_in_content(content) if content
snippet_fill_in_visibility(visibility) if visibility
end
end

View File

@ -968,10 +968,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.11.0.tgz#06edc30c58a539b2cb60ef30d61ce470c0f57f2b"
integrity sha512-IkiMrt3NY4IHonEgSHfv6JoJ+3McppZpt7XYgG6QtVtiCHFuwbkTH+gLMeZHG127AjtuHr54wS/cYp4MSNZZ4Q==
"@gitlab/ui@39.4.0":
version "39.4.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-39.4.0.tgz#7afacad3556c8bcfc136d38922c15a3fe7cd5cd5"
integrity sha512-OZRGLS/308paAHRSoiDCR+ZDuXZkE4tzFRdHlTBO0P6/7ZKLtIL6koNWa0Lpedlr2BfmGxkjvAsotvHk2F9U1w==
"@gitlab/ui@39.6.0":
version "39.6.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-39.6.0.tgz#d39ee45a6b629498a60af9683ecc9c2d50486c13"
integrity sha512-RQBD4r8ii9xZ3Hwxfn3RKZkBWfberffX93MDWlxujD43DHJ0aE0Uhw/fqfE41i/uEUNqhhNVRtl77XLSlYP/iw==
dependencies:
"@popperjs/core" "^2.11.2"
bootstrap-vue "2.20.1"