Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-04-28 18:09:35 +00:00
parent 37ae6b54ba
commit 95e18e3283
77 changed files with 1228 additions and 929 deletions

View file

@ -17,10 +17,11 @@ function showTooltip(target, title) {
}
function genericSuccess(e) {
showTooltip(e.trigger, __('Copied'));
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
$(e.trigger).blur();
showTooltip(e.trigger, __('Copied'));
}
/**

View file

@ -164,48 +164,7 @@ class List {
}
addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
if (!this.findIssue(issue.id)) {
if (newIndex !== undefined) {
this.issues.splice(newIndex, 0, issue);
if (this.issues[newIndex - 1]) {
moveBeforeId = this.issues[newIndex - 1].id;
}
if (this.issues[newIndex + 1]) {
moveAfterId = this.issues[newIndex + 1].id;
}
} else {
this.issues.push(issue);
}
if (this.label) {
issue.addLabel(this.label);
}
if (this.assignee) {
if (listFrom && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
}
issue.addAssignee(this.assignee);
}
if (IS_EE && this.milestone) {
if (listFrom && listFrom.type === 'milestone') {
issue.removeMilestone(listFrom.milestone);
}
issue.addMilestone(this.milestone);
}
if (listFrom) {
this.issuesSize += 1;
this.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId);
}
}
boardsStore.addListIssue(this, issue, listFrom, newIndex);
}
moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {

View file

@ -125,6 +125,50 @@ const boardsStore = {
path: '',
});
},
addListIssue(list, issue, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
if (!list.findIssue(issue.id)) {
if (newIndex !== undefined) {
list.issues.splice(newIndex, 0, issue);
if (list.issues[newIndex - 1]) {
moveBeforeId = list.issues[newIndex - 1].id;
}
if (list.issues[newIndex + 1]) {
moveAfterId = list.issues[newIndex + 1].id;
}
} else {
list.issues.push(issue);
}
if (list.label) {
issue.addLabel(list.label);
}
if (list.assignee) {
if (listFrom && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
}
issue.addAssignee(list.assignee);
}
if (IS_EE && list.milestone) {
if (listFrom && listFrom.type === 'milestone') {
issue.removeMilestone(listFrom.milestone);
}
issue.addMilestone(list.milestone);
}
if (listFrom) {
list.issuesSize += 1;
list.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId);
}
}
},
welcomeIsHidden() {
return parseBoolean(Cookies.get('issue_board_welcome_hidden'));
},

View file

@ -437,7 +437,11 @@ export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) {
// This method will check whether the discussion is still applicable
// to the diff line in question regarding different versions of the MR
export function isDiscussionApplicableToLine({ discussion, diffPosition, latestDiff }) {
const { line_code, ...diffPositionCopy } = diffPosition;
const { line_code, ...dp } = diffPosition;
// Removing `line_range` from diffPosition because the backend does not
// yet consistently return this property. This check can be removed,
// once this is addressed. see https://gitlab.com/gitlab-org/gitlab/-/issues/213010
const { line_range: dpNotUsed, ...diffPositionCopy } = dp;
if (discussion.original_position && discussion.position) {
const discussionPositions = [
@ -446,7 +450,14 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD
...(discussion.positions || []),
];
return discussionPositions.some(position => isEqual(position, diffPositionCopy));
const removeLineRange = position => {
const { line_range: pNotUsed, ...positionNoLineRange } = position;
return positionNoLineRange;
};
return discussionPositions
.map(removeLineRange)
.some(position => isEqual(position, diffPositionCopy));
}
// eslint-disable-next-line

View file

@ -95,22 +95,18 @@ export default {
@click="onClickCollapsedIcon"
>
<i class="fa fa-users" aria-hidden="true"> </i>
<gl-loading-icon v-if="loading" class="js-participants-collapsed-loading-icon" />
<span v-else class="js-participants-collapsed-count"> {{ participantCount }} </span>
<gl-loading-icon v-if="loading" />
<span v-else data-testid="collapsed-count"> {{ participantCount }} </span>
</div>
<div v-if="showParticipantLabel" class="title hide-collapsed">
<gl-loading-icon
v-if="loading"
:inline="true"
class="js-participants-expanded-loading-icon"
/>
<gl-loading-icon v-if="loading" :inline="true" />
{{ participantLabel }}
</div>
<div class="participants-list hide-collapsed">
<div
v-for="participant in visibleParticipants"
:key="participant.id"
class="participants-author js-participants-author"
class="participants-author"
>
<a :href="participant.web_url" class="author-link">
<user-avatar-image
@ -125,11 +121,7 @@ export default {
</div>
</div>
<div v-if="hasMoreParticipants" class="participants-more hide-collapsed">
<button
type="button"
class="btn-transparent btn-link js-toggle-participants-button"
@click="toggleMoreParticipants"
>
<button type="button" class="btn-transparent btn-link" @click="toggleMoreParticipants">
{{ toggleLabel }}
</button>
</div>

View file

@ -67,6 +67,7 @@ export default {
<template>
<gl-deprecated-button
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
v-gl-tooltip.hover.blur
:class="cssClass"
:title="title"
:data-clipboard-text="clipboardText"

View file

@ -0,0 +1,30 @@
<script>
import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
export default {
components: {
ToastEditor: () =>
import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
toast => toast.Editor,
),
},
props: {
value: {
type: String,
required: true,
},
},
methods: {
onContentChanged() {
this.$emit('input', this.getMarkdown());
},
getMarkdown() {
return this.$refs.editor.invoke('getMarkdown');
},
},
};
</script>
<template>
<toast-editor ref="editor" :initial-value="value" @change="onContentChanged" />
</template>

View file

@ -58,8 +58,6 @@ module SearchHelper
ns_('SearchResults|comment', 'SearchResults|comments', count)
when 'projects'
ns_('SearchResults|project', 'SearchResults|projects', count)
when 'snippet_blobs'
ns_('SearchResults|snippet result', 'SearchResults|snippet results', count)
when 'snippet_titles'
ns_('SearchResults|snippet', 'SearchResults|snippets', count)
when 'users'

View file

@ -42,72 +42,6 @@ module SnippetsHelper
(lower..upper).to_a
end
# Returns a sorted set of lines to be included in a snippet preview.
# This ensures matching adjacent lines do not display duplicated
# surrounding code.
#
# @returns Array, unique and sorted.
def matching_lines(lined_content, surrounding_lines, query)
used_lines = []
lined_content.each_with_index do |line, line_number|
used_lines.concat bounded_line_numbers(
line_number,
0,
lined_content.size,
surrounding_lines
) if line.downcase.include?(query.downcase)
end
used_lines.uniq.sort
end
# 'Chunkify' entire snippet. Splits the snippet data into matching lines +
# surrounding_lines() worth of unmatching lines.
#
# @returns a hash with {snippet_object, snippet_chunks:{data,start_line}}
def chunk_snippet(snippet, query, surrounding_lines = 3)
lined_content = snippet.content.split("\n")
used_lines = matching_lines(lined_content, surrounding_lines, query)
snippet_chunk = []
snippet_chunks = []
snippet_start_line = 0
last_line = -1
# Go through each used line, and add consecutive lines as a single chunk
# to the snippet chunk array.
used_lines.each do |line_number|
if last_line < 0
# Start a new chunk.
snippet_start_line = line_number
snippet_chunk << lined_content[line_number]
elsif last_line == line_number - 1
# Consecutive line, continue chunk.
snippet_chunk << lined_content[line_number]
else
# Non-consecutive line, add chunk to chunk array.
snippet_chunks << {
data: snippet_chunk.join("\n"),
start_line: snippet_start_line + 1
}
# Start a new chunk.
snippet_chunk = [lined_content[line_number]]
snippet_start_line = line_number
end
last_line = line_number
end
# Add final chunk to chunk array
snippet_chunks << {
data: snippet_chunk.join("\n"),
start_line: snippet_start_line + 1
}
# Return snippet with chunk array
{ snippet_object: snippet, snippet_chunks: snippet_chunks }
end
def snippet_embed_tag(snippet)
content_tag(:script, nil, src: gitlab_snippet_url(snippet, format: :js))
end

View file

@ -1786,7 +1786,6 @@ class User < ApplicationRecord
end
def check_email_restrictions
return unless Feature.enabled?(:email_restrictions)
return unless Gitlab::CurrentSettings.email_restrictions_enabled?
restrictions = Gitlab::CurrentSettings.email_restrictions

View file

@ -91,6 +91,8 @@ class IssuableBaseService < BaseService
elsif params[label_key]
params[label_id_key] = labels_service.find_or_create_by_titles(label_key, find_only: find_only).map(&:id)
end
params.delete(label_key) if params[label_key].nil?
end
def filter_labels_in_param(key)

View file

@ -49,20 +49,19 @@
= f.label :domain_blacklist, 'Blacklisted domains for sign-ups', class: 'label-bold'
= f.text_area :domain_blacklist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
.form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com
- if Feature.enabled?(:email_restrictions)
.form-group
= f.label :email_restrictions_enabled, _('Email restrictions'), class: 'label-bold'
.form-check
= f.check_box :email_restrictions_enabled, class: 'form-check-input'
= f.label :email_restrictions_enabled, class: 'form-check-label' do
= _('Enable email restrictions for sign ups')
.form-group
= f.label :email_restrictions, _('Email restrictions for sign-ups'), class: 'label-bold'
= f.text_area :email_restrictions, class: 'form-control', rows: 4
.form-text.text-muted
- supported_syntax_link_url = 'https://github.com/google/re2/wiki/Syntax'
- supported_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: supported_syntax_link_url }
= _('Restricts sign-ups for email addresses that match the given regex. See the %{supported_syntax_link_start}supported syntax%{supported_syntax_link_end} for more information.').html_safe % { supported_syntax_link_start: supported_syntax_link_start, supported_syntax_link_end: '</a>'.html_safe }
.form-group
= f.label :email_restrictions_enabled, _('Email restrictions'), class: 'label-bold'
.form-check
= f.check_box :email_restrictions_enabled, class: 'form-check-input'
= f.label :email_restrictions_enabled, class: 'form-check-label' do
= _('Enable email restrictions for sign ups')
.form-group
= f.label :email_restrictions, _('Email restrictions for sign-ups'), class: 'label-bold'
= f.text_area :email_restrictions, class: 'form-control', rows: 4
.form-text.text-muted
- supported_syntax_link_url = 'https://github.com/google/re2/wiki/Syntax'
- supported_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: supported_syntax_link_url }
= _('Restricts sign-ups for email addresses that match the given regex. See the %{supported_syntax_link_start}supported syntax%{supported_syntax_link_end} for more information.').html_safe % { supported_syntax_link_start: supported_syntax_link_start, supported_syntax_link_end: '</a>'.html_safe }
.form-group
= f.label :after_sign_up_text, class: 'label-bold'

View file

@ -24,7 +24,7 @@
= users
- elsif @show_snippets
= search_filter_link 'snippet_titles', _("Titles and Filenames"), search: { snippets: true, group_id: nil, project_id: nil }
= search_filter_link 'snippet_titles', _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }
- else
= search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' }
= search_filter_link 'issues', _("Issues")

View file

@ -1,50 +0,0 @@
- snippet_blob = chunk_snippet(snippet_blob, @search_term)
- snippet = snippet_blob[:snippet_object]
- snippet_chunks = snippet_blob[:snippet_chunks]
- snippet_path = gitlab_snippet_path(snippet)
.search-result-row.snippet-row
= image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: ''
.title
= link_to gitlab_snippet_path(snippet) do
= snippet.title
.snippet-info
= snippet.to_reference
&middot;
authored
= time_ago_with_tooltip(snippet.created_at)
by
= link_to user_snippets_path(snippet.author) do
= snippet.author_name
.file-holder.my-2
.js-file-title.file-title-flex-parent
= link_to snippet_path do
%i.fa.fa-file
%strong= snippet.file_name
- if markup?(snippet.file_name)
.file-content.md
- snippet_chunks.each do |chunk|
- unless chunk[:data].empty?
= markup(snippet.file_name, chunk[:data])
- else
.file-content.code
.nothing-here-block= _("Empty file")
- else
.file-content.code.js-syntax-highlight
.line-numbers
- snippet_chunks.each do |chunk|
- unless chunk[:data].empty?
- Gitlab::Git::Util.count_lines(chunk[:data]).times do |index|
- offset = defined?(chunk[:start_line]) ? chunk[:start_line] : 1
- i = index + offset
= link_to snippet_path+"#L#{i}", id: "L#{i}", rel: "#L#{i}", class: "diff-line-num" do
%i.fa.fa-link
= i
.blob-content
- snippet_chunks.each do |chunk|
- unless chunk[:data].empty?
= highlight(snippet.file_name, chunk[:data])
- else
.file-content.code
.nothing-here-block= _("Empty file")

View file

@ -0,0 +1,5 @@
---
title: Add non_archived argument to issues API endpoint
merge_request: 30381
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Fixes overlapping tooltips when clicking copy buttons
merge_request: 30622
author:
type: fixed

View file

@ -0,0 +1,5 @@
---
title: removes store logic from issue board models
merge_request: 21408
author: nuwe1
type: other

View file

@ -0,0 +1,5 @@
---
title: backfill environment_id on deployment_merge_requests
merge_request: 27219
author:
type: other

View file

@ -0,0 +1,5 @@
---
title: Add option to restrict emails that match a configured regular expression
merge_request: 30548
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Rename Snippet search results title
merge_request: 29599
author:
type: other

View file

@ -17,7 +17,6 @@ If you want to create a changelog entry for GitLab EE, run the following instead
bin/changelog --ee -m %<mr_iid>s "%<mr_title>s"
```
Note: Merge requests with %<labels>s do not trigger this check.
MSG
def check_changelog_yaml(path)
@ -57,7 +56,7 @@ end
if git.modified_files.include?("CHANGELOG.md")
fail "**CHANGELOG.md was edited.** Please remove the additions and create a CHANGELOG entry.\n\n" +
format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: sanitized_mr_title, labels: changelog.presented_no_changelog_labels)
format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: sanitized_mr_title)
end
changelog_found = changelog.found
@ -67,5 +66,5 @@ if changelog_found
check_changelog_path(changelog_found)
elsif changelog.needed?
message "**[CHANGELOG missing](https://docs.gitlab.com/ee/development/changelog.html)**: If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message.\n\n" +
format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: sanitized_mr_title, labels: changelog.presented_no_changelog_labels)
format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: sanitized_mr_title)
end

View file

@ -7,14 +7,10 @@ That's OK as long as you're refactoring existing code,
but please consider adding any of the %<labels>s labels.
MSG
def presented_no_changelog_labels
NO_SPECS_LABELS.map { |label| "~#{label}" }.join(', ')
end
has_app_changes = !helper.all_changed_files.grep(%r{\A(ee/)?(app|lib|db/(geo/)?(post_)?migrate)/}).empty?
has_spec_changes = !helper.all_changed_files.grep(%r{\A(ee/)?spec/}).empty?
new_specs_needed = (gitlab.mr_labels & NO_SPECS_LABELS).empty?
if has_app_changes && !has_spec_changes && new_specs_needed
warn format(NO_NEW_SPEC_MESSAGE, labels: presented_no_changelog_labels), sticky: false
warn format(NO_NEW_SPEC_MESSAGE, labels: helper.labels_list(NO_SPECS_LABELS)), sticky: false
end

View file

@ -19,7 +19,6 @@ class DropForkedProjectLinksFk < ActiveRecord::Migration[6.0]
unless foreign_key_exists?(:forked_project_links, :projects, column: :forked_to_project_id)
# rubocop: disable Migration/WithLockRetriesWithoutDdlTransaction
with_lock_retries do
# rubocop: disable Migration/AddConcurrentForeignKey
add_foreign_key :forked_project_links, :projects, column: :forked_to_project_id, on_delete: :cascade, validate: false
end
# rubocop: enable Migration/WithLockRetriesWithoutDdlTransaction

View file

@ -1,6 +1,5 @@
# frozen_string_literal: true
# rubocop: disable Migration/AddConcurrentForeignKey
# rubocop: disable Migration/WithLockRetriesWithoutDdlTransaction
class AddProtectedTagCreateAccessLevelsUserIdForeignKey < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers

View file

@ -1,6 +1,5 @@
# frozen_string_literal: true
# rubocop: disable Migration/AddConcurrentForeignKey
# rubocop: disable Migration/WithLockRetriesWithoutDdlTransaction
class AddProtectedBranchMergeAccessLevelsUserIdForeignKey < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers

View file

@ -1,6 +1,5 @@
# frozen_string_literal: true
# rubocop: disable Migration/AddConcurrentForeignKey
# rubocop: disable Migration/WithLockRetriesWithoutDdlTransaction
class AddPathLocksUserIdForeignKey < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers

View file

@ -1,6 +1,5 @@
# frozen_string_literal: true
# rubocop: disable Migration/AddConcurrentForeignKey
# rubocop: disable Migration/WithLockRetriesWithoutDdlTransaction
class AddProtectedBranchPushAccessLevelsUserIdForeignKey < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers

View file

@ -1,6 +1,5 @@
# frozen_string_literal: true
# rubocop: disable Migration/AddConcurrentForeignKey
# rubocop: disable Migration/WithLockRetriesWithoutDdlTransaction
class AddU2fRegistrationsUserIdForeignKey < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers

View file

@ -0,0 +1,54 @@
# frozen_string_literal: true
class BackfillEnvironmentIdOnDeploymentMergeRequests < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
BATCH_SIZE = 400
DELAY = 1.minute
disable_ddl_transaction!
def up
max_mr_id = DeploymentMergeRequest
.select(:merge_request_id)
.distinct
.order(merge_request_id: :desc)
.limit(1)
.pluck(:merge_request_id)
.first || 0
last_mr_id = 0
step = 0
while last_mr_id < max_mr_id
stop =
DeploymentMergeRequest
.select(:merge_request_id)
.distinct
.where('merge_request_id > ?', last_mr_id)
.order(:merge_request_id)
.offset(BATCH_SIZE)
.limit(1)
.pluck(:merge_request_id)
.first
stop ||= max_mr_id
migrate_in(
step * DELAY,
'BackfillEnvironmentIdDeploymentMergeRequests',
[last_mr_id + 1, stop]
)
last_mr_id = stop
step += 1
end
end
def down
# no-op
# this migration is designed to delete duplicated data
end
end

View file

@ -13419,6 +13419,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200311214912
20200312053852
20200312125121
20200312134637
20200312160532
20200312163407
20200313101649

View file

@ -67,6 +67,7 @@ GET /issues?confidential=true
| `updated_before` | datetime | no | Return issues updated on or before the given time |
| `confidential` | boolean | no | Filter confidential or public issues. |
| `not` | Hash | no | Return issues that do not match the parameters supplied. Accepts: `labels`, `milestone`, `author_id`, `author_username`, `assignee_id`, `assignee_username`, `my_reaction_emoji`, `search`, `in` |
| `non_archived` | boolean | no | Return issues only from non-archived projects. If `false`, response will return issues from both archived and non-archived projects. Default is `true`. _(Introduced in [GitLab 13.0](https://gitlab.com/gitlab-org/gitlab/-/issues/197170))_ |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/issues

View file

@ -17,7 +17,7 @@ GET /search
| `scope` | string | yes | The scope to search in |
| `search` | string | yes | The search query |
Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs, users.
Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, users.
If Elasticsearch is enabled additional scopes available are blobs, wiki_blobs and commits. Find more about [the feature](../integration/elasticsearch.md). **(STARTER)**
@ -253,39 +253,6 @@ Example response:
]
```
### Scope: snippet_blobs
This scope will be disabled after GitLab 13.0.
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/search?scope=snippet_blobs&search=test
```
Example response:
```json
[
{
"id": 50,
"title": "Sample file",
"file_name": "file.rb",
"description": "Simple ruby file",
"author": {
"id": 1,
"name": "Administrator",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
"web_url": "http://localhost:3000/root"
},
"updated_at": "2018-02-06T12:49:29.104Z",
"created_at": "2017-11-28T08:20:18.071Z",
"project_id": 9,
"web_url": "http://localhost:3000/root/jira-test/snippets/50"
}
]
```
### Scope: wiki_blobs **(STARTER)**
This scope is available only if [Elasticsearch](../integration/elasticsearch.md) is enabled.

View file

@ -64,7 +64,7 @@ class AddNotValidForeignKeyToEmailsUser < ActiveRecord::Migration[5.2]
def up
# safe to use: it requires short lock on the table since we don't validate the foreign key
add_foreign_key :emails, :users, on_delete: :cascade, validate: false # rubocop:disable Migration/AddConcurrentForeignKey
add_foreign_key :emails, :users, on_delete: :cascade, validate: false
end
def down

View file

@ -562,7 +562,7 @@ To use Auto Monitoring:
1. [Install and configure the requirements](index.md#requirements).
1. [Enable Auto DevOps](index.md#enablingdisabling-auto-devops), if you haven't done already.
1. Navigate to your project's **{rocket}** **CI/CD > Pipelines** and click **Run pipeline**.
1. Navigate to your project's **{rocket}** **CI/CD > Pipelines** and click **Run Pipeline**.
1. After the pipeline finishes successfully, open the
[monitoring dashboard for a deployed environment](../../ci/environments.md#monitoring-environments)
to view the metrics of your deployed application. To view the metrics of the

View file

@ -117,7 +117,7 @@ Once synchronized, changing the field mapped to `id` and `externalId` will likel
### Okta configuration steps
The SAML application that was created during [Single sign-on](index.md) setup for [Okta](The SAML application that was created during [Single sign-on](index.md) setup for [Okta](https://developer.okta.com/docs/guides/saml-application-setup/overview/) now needs to be set up for SCIM.
The SAML application that was created during [Single sign-on](index.md#okta-setup-notes) setup for [Okta](https://developer.okta.com/docs/guides/saml-application-setup/overview/) now needs to be set up for SCIM.
1. Sign in to Okta.
1. If you see an **Admin** button in the top right, click the button. This will
@ -138,6 +138,10 @@ The SAML application that was created during [Single sign-on](index.md) setup fo
- For **API Token** enter the SCIM token obtained from the GitLab SCIM configuration page
1. Click 'Test API Credentials' to verify configuration.
1. Click **Save** to apply the settings.
1. After saving the API integration details, new settings tabs will appear on the left. Choose **To App**.
1. Click **Edit**.
1. Check the box to **Enable** for both **Create Users** and **Deactivate Users**.
1. Click **Save**.
1. Assign users in the **Assignments** tab. Assigned users will be created and
managed in your GitLab group.

View file

@ -5,7 +5,7 @@ module API
module SearchHelpers
def self.global_search_scopes
# This is a separate method so that EE can redefine it.
%w(projects issues merge_requests milestones snippet_titles snippet_blobs users)
%w(projects issues merge_requests milestones snippet_titles users)
end
def self.group_search_scopes

View file

@ -95,6 +95,8 @@ module API
use :issues_params
optional :scope, type: String, values: %w[created-by-me assigned-to-me created_by_me assigned_to_me all], default: 'created_by_me',
desc: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`'
optional :non_archived, type: Boolean, default: true,
desc: 'Return issues from non archived projects'
end
get do
authenticate! unless params[:scope] == 'all'

View file

@ -17,7 +17,6 @@ module API
blobs: Entities::Blob,
wiki_blobs: Entities::Blob,
snippet_titles: Entities::Snippet,
snippet_blobs: Entities::Snippet,
users: Entities::UserBasic
}.freeze
@ -36,7 +35,7 @@ module API
end
def snippets?
%w(snippet_blobs snippet_titles).include?(params[:scope]).to_s
%w(snippet_titles).include?(params[:scope]).to_s
end
def entity

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# BackfillEnvironmentIdDeploymentMergeRequests deletes duplicates
# from deployment_merge_requests table and backfills environment_id
class BackfillEnvironmentIdDeploymentMergeRequests
def perform(start_mr_id, stop_mr_id)
start_mr_id = Integer(start_mr_id)
stop_mr_id = Integer(stop_mr_id)
ActiveRecord::Base.connection.execute(<<~SQL)
DELETE FROM deployment_merge_requests
WHERE (deployment_id, merge_request_id) in (
SELECT t.deployment_id, t.merge_request_id FROM (
SELECT mrd.merge_request_id, mrd.deployment_id, ROW_NUMBER() OVER w AS rnum
FROM deployment_merge_requests as mrd
INNER JOIN "deployments" ON "deployments"."id" = "mrd"."deployment_id"
WHERE mrd.merge_request_id BETWEEN #{start_mr_id} AND #{stop_mr_id}
WINDOW w AS (
PARTITION BY merge_request_id, deployments.environment_id
ORDER BY deployments.id
)
) t
WHERE t.rnum > 1
);
SQL
ActiveRecord::Base.connection.execute(<<~SQL)
UPDATE deployment_merge_requests
SET environment_id = deployments.environment_id
FROM deployments
WHERE deployments.id = "deployment_merge_requests".deployment_id
AND "deployment_merge_requests".environment_id IS NULL
AND "deployment_merge_requests".merge_request_id BETWEEN #{start_mr_id} AND #{stop_mr_id}
SQL
end
end
end
end

View file

@ -14,10 +14,6 @@ module Gitlab
@found ||= git.added_files.find { |path| path =~ %r{\A(ee/)?(changelogs/unreleased)(-ee)?/} }
end
def presented_no_changelog_labels
NO_CHANGELOG_LABELS.map { |label| "~#{label}" }.join(', ')
end
def sanitized_mr_title
gitlab.mr_json["title"].gsub(/^WIP: */, '').gsub(/`/, '\\\`')
end

View file

@ -198,11 +198,14 @@ module Gitlab
(labels & gitlab_helper.mr_labels) == labels
end
def labels_list(labels, sep: ', ')
labels.map { |label| %Q{~"#{label}"} }.join(sep)
end
def prepare_labels_for_mr(labels)
return '' unless labels.any?
labels_list = labels.map { |label| %Q{~"#{label}"} }.join(' ')
"/label #{labels_list}"
"/label #{labels_list(labels, sep: ' ')}"
end
private

View file

@ -18104,11 +18104,6 @@ msgid_plural "SearchResults|snippets"
msgstr[0] ""
msgstr[1] ""
msgid "SearchResults|snippet result"
msgid_plural "SearchResults|snippet results"
msgstr[0] ""
msgstr[1] ""
msgid "SearchResults|user"
msgid_plural "SearchResults|users"
msgstr[0] ""
@ -21463,10 +21458,10 @@ msgstr ""
msgid "ThreatMonitoring|Application firewall not detected"
msgstr ""
msgid "ThreatMonitoring|Container Network Policy"
msgid "ThreatMonitoring|Container Network Policies are not installed or have been disabled. To view this data, ensure your Network Policies are installed and enabled for your cluster."
msgstr ""
msgid "ThreatMonitoring|Container NetworkPolicies are not installed or has been disabled. To view this data, ensure you NetworkPolicies are installed and enabled for your cluster."
msgid "ThreatMonitoring|Container Network Policy"
msgstr ""
msgid "ThreatMonitoring|Container NetworkPolicies not detected"
@ -21499,7 +21494,7 @@ msgstr ""
msgid "ThreatMonitoring|Something went wrong, unable to fetch statistics"
msgstr ""
msgid "ThreatMonitoring|The firewall is not installed or has been disabled. To view this data, ensure you firewall is installed and enabled for your cluster."
msgid "ThreatMonitoring|The firewall is not installed or has been disabled. To view this data, ensure the web application firewall is installed and enabled for your cluster."
msgstr ""
msgid "ThreatMonitoring|The graph below is an overview of traffic coming to your application as tracked by the Web Application Firewall (WAF). View the docs for instructions on how to access the WAF logs to see what type of malicious traffic is trying to access your app. The docs link is also accessible by clicking the \"?\" icon next to the title below."
@ -21755,7 +21750,7 @@ msgstr ""
msgid "Title:"
msgstr ""
msgid "Titles and Filenames"
msgid "Titles and Descriptions"
msgstr ""
msgid "To"

View file

@ -44,6 +44,8 @@
"@gitlab/visual-review-tools": "1.6.1",
"@sentry/browser": "^5.10.2",
"@sourcegraph/code-host-integration": "0.0.37",
"@toast-ui/editor": "^2.0.1",
"@toast-ui/vue-editor": "^2.0.1",
"apollo-cache-inmemory": "^1.6.3",
"apollo-client": "^2.6.4",
"apollo-link": "^1.2.11",
@ -60,6 +62,7 @@
"chart.js": "2.7.2",
"classlist-polyfill": "^1.2.0",
"clipboard": "^1.7.1",
"codemirror": "^5.48.4",
"codesandbox-api": "0.0.23",
"compression-webpack-plugin": "^3.0.1",
"copy-webpack-plugin": "^5.0.5",

View file

@ -10,17 +10,29 @@ module RuboCop
MSG = '`add_foreign_key` requires downtime, use `add_concurrent_foreign_key` instead'.freeze
def_node_matcher :false_node?, <<~PATTERN
(false)
PATTERN
def on_send(node)
return unless in_migration?(node)
name = node.children[1]
add_offense(node, location: :selector) if name == :add_foreign_key
if name == :add_foreign_key && !not_valid_fk?(node)
add_offense(node, location: :selector)
end
end
def method_name(node)
node.children.first
end
def not_valid_fk?(node)
node.each_node(:pair).any? do |pair|
pair.children[0].children[0] == :validate && false_node?(pair.children[1])
end
end
end
end
end

View file

@ -11,7 +11,7 @@ describe 'Search Snippets' do
visit dashboard_snippets_path
submit_search('Middle')
select_search_scope('Titles and Filenames')
select_search_scope('Titles and Descriptions')
expect(page).to have_link(public_snippet.title)
expect(page).to have_link(private_snippet.title)

View file

@ -174,15 +174,16 @@ describe MergeRequestsFinder do
deployment1 = create(
:deployment,
project: project_with_repo,
sha: project_with_repo.commit.id,
merge_requests: [merge_request1, merge_request2]
sha: project_with_repo.commit.id
)
create(
deployment2 = create(
:deployment,
project: project_with_repo,
sha: project_with_repo.commit.id,
merge_requests: [merge_request3]
sha: project_with_repo.commit.id
)
deployment1.link_merge_requests(MergeRequest.where(id: [merge_request1.id, merge_request2.id]))
deployment2.link_merge_requests(MergeRequest.where(id: merge_request3.id))
params = { deployment_id: deployment1.id }
merge_requests = described_class.new(user, params).execute

View file

@ -0,0 +1,17 @@
export const Editor = {
props: {
initialValue: {
type: String,
required: true,
},
},
render(h) {
return h('div');
},
};
export const Viewer = {
render(h) {
return h('div');
},
};

View file

@ -1041,6 +1041,66 @@ describe('boardsStore', () => {
});
});
describe('addListIssue', () => {
let list;
const issue1 = new ListIssue({
title: 'Testing',
id: 2,
iid: 2,
confidential: false,
labels: [
{
color: '#ff0000',
description: 'testing;',
id: 5000,
priority: undefined,
textColor: 'white',
title: 'Test',
},
],
assignees: [],
});
const issue2 = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [
{
id: 1,
title: 'test',
color: 'red',
description: 'testing',
},
],
assignees: [
{
id: 1,
name: 'name',
username: 'username',
avatar_url: 'http://avatar_url',
},
],
real_path: 'path/to/issue',
});
beforeEach(() => {
list = new List(listObj);
list.addIssue(issue1);
setupDefaultResponses();
});
it('adds issues that are not already on the list', () => {
expect(list.findIssue(issue2.id)).toBe(undefined);
expect(list.issues).toEqual([issue1]);
boardsStore.addListIssue(list, issue2);
expect(list.findIssue(issue2.id)).toBe(issue2);
expect(list.issues.length).toBe(2);
expect(list.issues).toEqual([issue1, issue2]);
});
});
describe('updateIssue', () => {
let issue;
let patchSpy;

View file

@ -503,11 +503,16 @@ describe('DiffsStoreUtils', () => {
},
};
// When multi line comments are fully implemented `line_code` will be
// included in all requests. Until then we need to ensure the logic does
// not change when it is included only in the "comparison" argument.
const lineRange = { start_line_code: 'abc_1_1', end_line_code: 'abc_1_2' };
it('returns true when the discussion is up to date', () => {
expect(
utils.isDiscussionApplicableToLine({
discussion: discussions.upToDateDiscussion1,
diffPosition,
diffPosition: { ...diffPosition, line_range: lineRange },
latestDiff: true,
}),
).toBe(true);
@ -517,7 +522,7 @@ describe('DiffsStoreUtils', () => {
expect(
utils.isDiscussionApplicableToLine({
discussion: discussions.outDatedDiscussion1,
diffPosition,
diffPosition: { ...diffPosition, line_range: lineRange },
latestDiff: true,
}),
).toBe(false);
@ -534,6 +539,7 @@ describe('DiffsStoreUtils', () => {
diffPosition: {
...diffPosition,
lineCode: 'ABC_1',
line_range: lineRange,
},
latestDiff: true,
}),
@ -551,6 +557,7 @@ describe('DiffsStoreUtils', () => {
diffPosition: {
...diffPosition,
line_code: 'ABC_1',
line_range: lineRange,
},
latestDiff: true,
}),
@ -568,6 +575,7 @@ describe('DiffsStoreUtils', () => {
diffPosition: {
...diffPosition,
lineCode: 'ABC_1',
line_range: lineRange,
},
latestDiff: false,
}),

View file

@ -1,6 +1,4 @@
import $ from 'jquery';
import MockAdapter from 'axios-mock-adapter';
import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
import axios from '~/lib/utils/axios_utils';
import { getSelector, dismiss, inserted } from '~/feature_highlight/feature_highlight_helper';
import { togglePopover } from '~/shared/popover';
@ -17,34 +15,23 @@ describe('feature highlight helper', () => {
});
describe('dismiss', () => {
let mock;
const context = {
hide: () => {},
attr: () => '/-/callouts/dismiss',
};
beforeEach(() => {
mock = new MockAdapter(axios);
spyOn(togglePopover, 'call').and.callFake(() => {});
spyOn(context, 'hide').and.callFake(() => {});
jest.spyOn(axios, 'post').mockResolvedValue();
jest.spyOn(togglePopover, 'call').mockImplementation(() => {});
jest.spyOn(context, 'hide').mockImplementation(() => {});
dismiss.call(context);
});
afterEach(() => {
mock.restore();
});
it('calls persistent dismissal endpoint', done => {
const spy = jasmine.createSpy('dismiss-endpoint-hit');
mock.onPost('/-/callouts/dismiss').reply(spy);
getSetTimeoutPromise()
.then(() => {
expect(spy).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
it('calls persistent dismissal endpoint', () => {
expect(axios.post).toHaveBeenCalledWith(
'/-/callouts/dismiss',
expect.objectContaining({ feature_name: undefined }),
);
});
it('calls hide popover', () => {
@ -65,7 +52,7 @@ describe('feature highlight helper', () => {
},
};
spyOn($.fn, 'on').and.callFake(event => {
jest.spyOn($.fn, 'on').mockImplementation(event => {
expect(event).toEqual('click');
done();
});

View file

@ -3,34 +3,20 @@ import domContentLoaded from '~/feature_highlight/feature_highlight_options';
describe('feature highlight options', () => {
describe('domContentLoaded', () => {
it('should not call highlightFeatures when breakpoint is xs', () => {
jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs');
it.each`
breakPoint | shouldCall
${'xs'} | ${false}
${'sm'} | ${false}
${'md'} | ${false}
${'lg'} | ${false}
${'xl'} | ${true}
`(
'when breakpoint is $breakPoint should call highlightFeatures is $shouldCall',
({ breakPoint, shouldCall }) => {
jest.spyOn(bp, 'getBreakpointSize').mockReturnValue(breakPoint);
expect(domContentLoaded()).toBe(false);
});
it('should not call highlightFeatures when breakpoint is sm', () => {
jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm');
expect(domContentLoaded()).toBe(false);
});
it('should not call highlightFeatures when breakpoint is md', () => {
jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md');
expect(domContentLoaded()).toBe(false);
});
it('should not call highlightFeatures when breakpoint is not xl', () => {
jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg');
expect(domContentLoaded()).toBe(false);
});
it('should call highlightFeatures when breakpoint is xl', () => {
jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl');
expect(domContentLoaded()).toBe(true);
});
expect(domContentLoaded()).toBe(shouldCall);
},
);
});
});

View file

@ -4,6 +4,8 @@ import * as featureHighlight from '~/feature_highlight/feature_highlight';
import * as popover from '~/shared/popover';
import axios from '~/lib/utils/axios_utils';
jest.mock('~/shared/popover');
describe('feature highlight', () => {
beforeEach(() => {
setFixtures(`
@ -28,7 +30,7 @@ describe('feature highlight', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet('/test').reply(200);
spyOn(window, 'addEventListener');
jest.spyOn(window, 'addEventListener').mockImplementation(() => {});
featureHighlight.setupFeatureHighlightPopover('test', 0);
});
@ -44,27 +46,21 @@ describe('feature highlight', () => {
});
it('setup mouseenter', () => {
const toggleSpy = spyOn(popover.togglePopover, 'call');
$(selector).trigger('mouseenter');
expect(toggleSpy).toHaveBeenCalledWith(jasmine.any(Object), true);
expect(popover.mouseenter).toHaveBeenCalledWith(expect.any(Object));
});
it('setup debounced mouseleave', done => {
const toggleSpy = spyOn(popover.togglePopover, 'call');
it('setup debounced mouseleave', () => {
$(selector).trigger('mouseleave');
// Even though we've set the debounce to 0ms, setTimeout is needed for the debounce
setTimeout(() => {
expect(toggleSpy).toHaveBeenCalledWith(jasmine.any(Object), false);
done();
}, 0);
expect(popover.debouncedMouseleave).toHaveBeenCalled();
});
it('setup show.bs.popover', () => {
$(selector).trigger('show.bs.popover');
expect(window.addEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function), {
expect(window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function), {
once: true,
});
});
@ -72,23 +68,6 @@ describe('feature highlight', () => {
it('removes disabled attribute', () => {
expect($('.js-feature-highlight').is(':disabled')).toEqual(false);
});
it('displays popover', () => {
expect(document.querySelector(selector).getAttribute('aria-describedby')).toBeFalsy();
$(selector).trigger('mouseenter');
expect(document.querySelector(selector).getAttribute('aria-describedby')).toBeTruthy();
});
it('toggles when clicked', () => {
$(selector).trigger('mouseenter');
const popoverId = $(selector).attr('aria-describedby');
const toggleSpy = spyOn(popover.togglePopover, 'call');
$(`#${popoverId} .dismiss-feature-highlight`).click();
expect(toggleSpy).toHaveBeenCalled();
});
});
describe('findHighestPriorityFeature', () => {

View file

@ -0,0 +1,31 @@
import { shallowMount } from '@vue/test-utils';
import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue';
describe('EditFormButtons', () => {
let wrapper;
const mountComponent = propsData => shallowMount(EditFormButtons, { propsData });
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('displays "Unlock" when locked', () => {
wrapper = mountComponent({
isLocked: true,
updateLockedAttribute: () => {},
});
expect(wrapper.text()).toContain('Unlock');
});
it('displays "Lock" when unlocked', () => {
wrapper = mountComponent({
isLocked: false,
updateLockedAttribute: () => {},
});
expect(wrapper.text()).toContain('Lock');
});
});

View file

@ -0,0 +1,206 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Participants from '~/sidebar/components/participants/participants.vue';
const PARTICIPANT = {
id: 1,
state: 'active',
username: 'marcene',
name: 'Allie Will',
web_url: 'foo.com',
avatar_url: 'gravatar.com/avatar/xxx',
};
const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }];
describe('Participants', () => {
let wrapper;
const getMoreParticipantsButton = () => wrapper.find('button');
const getCollapsedParticipantsCount = () => wrapper.find('[data-testid="collapsed-count"]');
const mountComponent = propsData =>
shallowMount(Participants, {
propsData,
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('collapsed sidebar state', () => {
it('shows loading spinner when loading', () => {
wrapper = mountComponent({
loading: true,
});
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
});
it('does not show loading spinner not loading', () => {
wrapper = mountComponent({
loading: false,
});
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
});
it('shows participant count when given', () => {
wrapper = mountComponent({
loading: false,
participants: PARTICIPANT_LIST,
});
expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`);
});
it('shows full participant count when there are hidden participants', () => {
wrapper = mountComponent({
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 1,
});
expect(getCollapsedParticipantsCount().text()).toBe(`${PARTICIPANT_LIST.length}`);
});
});
describe('expanded sidebar state', () => {
it('shows loading spinner when loading', () => {
wrapper = mountComponent({
loading: true,
});
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
});
it('when only showing visible participants, shows an avatar only for each participant under the limit', () => {
const numberOfLessParticipants = 2;
wrapper = mountComponent({
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants,
});
wrapper.setData({
isShowingMoreParticipants: false,
});
return Vue.nextTick().then(() => {
expect(wrapper.findAll('.participants-author')).toHaveLength(numberOfLessParticipants);
});
});
it('when only showing all participants, each has an avatar', () => {
wrapper = mountComponent({
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
wrapper.setData({
isShowingMoreParticipants: true,
});
return Vue.nextTick().then(() => {
expect(wrapper.findAll('.participants-author')).toHaveLength(PARTICIPANT_LIST.length);
});
});
it('does not have more participants link when they can all be shown', () => {
const numberOfLessParticipants = 100;
wrapper = mountComponent({
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants,
});
expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants);
expect(getMoreParticipantsButton().exists()).toBe(false);
});
it('when too many participants, has more participants link to show more', () => {
wrapper = mountComponent({
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
wrapper.setData({
isShowingMoreParticipants: false,
});
return Vue.nextTick().then(() => {
expect(getMoreParticipantsButton().text()).toBe('+ 1 more');
});
});
it('when too many participants and already showing them, has more participants link to show less', () => {
wrapper = mountComponent({
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
wrapper.setData({
isShowingMoreParticipants: true,
});
return Vue.nextTick().then(() => {
expect(getMoreParticipantsButton().text()).toBe('- show less');
});
});
it('clicking more participants link emits event', () => {
wrapper = mountComponent({
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
expect(wrapper.vm.isShowingMoreParticipants).toBe(false);
getMoreParticipantsButton().trigger('click');
expect(wrapper.vm.isShowingMoreParticipants).toBe(true);
});
it('clicking on participants icon emits `toggleSidebar` event', () => {
wrapper = mountComponent({
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
const spy = jest.spyOn(wrapper.vm, '$emit');
wrapper.find('.sidebar-collapsed-icon').trigger('click');
return Vue.nextTick(() => {
expect(spy).toHaveBeenCalledWith('toggleSidebar');
spy.mockRestore();
});
});
});
describe('when not showing participants label', () => {
beforeEach(() => {
wrapper = mountComponent({
participants: PARTICIPANT_LIST,
showParticipantLabel: false,
});
});
it('does not show sidebar collapsed icon', () => {
expect(wrapper.contains('.sidebar-collapsed-icon')).toBe(false);
});
it('does not show participants label title', () => {
expect(wrapper.contains('.title')).toBe(false);
});
});
});

View file

@ -0,0 +1,135 @@
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import * as urlUtility from '~/lib/utils/url_utility';
import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import Mock from './mock_data';
describe('Sidebar mediator', () => {
const { mediator: mediatorMockData } = Mock;
let mock;
let mediator;
beforeEach(() => {
mock = new MockAdapter(axios);
mediator = new SidebarMediator(mediatorMockData);
});
afterEach(() => {
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
mock.restore();
});
it('assigns yourself ', () => {
mediator.assignYourself();
expect(mediator.store.currentUser).toEqual(mediatorMockData.currentUser);
expect(mediator.store.assignees[0]).toEqual(mediatorMockData.currentUser);
});
it('saves assignees', () => {
mock.onPut(mediatorMockData.endpoint).reply(200, {});
return mediator.saveAssignees('issue[assignee_ids]').then(resp => {
expect(resp.status).toEqual(200);
});
});
it('fetches the data', () => {
const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
mock.onGet(mediatorMockData.endpoint).reply(200, mockData);
const mockGraphQlData = Mock.graphQlResponseData;
const graphQlSpy = jest.spyOn(gqClient, 'query').mockReturnValue({
data: mockGraphQlData,
});
const spy = jest.spyOn(mediator, 'processFetchedData').mockReturnValue(Promise.resolve());
return mediator.fetch().then(() => {
expect(spy).toHaveBeenCalledWith(mockData, mockGraphQlData);
spy.mockRestore();
graphQlSpy.mockRestore();
});
});
it('processes fetched data', () => {
const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
mediator.processFetchedData(mockData);
expect(mediator.store.assignees).toEqual(mockData.assignees);
expect(mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate);
expect(mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent);
expect(mediator.store.participants).toEqual(mockData.participants);
expect(mediator.store.subscribed).toEqual(mockData.subscribed);
expect(mediator.store.timeEstimate).toEqual(mockData.time_estimate);
expect(mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent);
});
it('sets moveToProjectId', () => {
const projectId = 7;
const spy = jest.spyOn(mediator.store, 'setMoveToProjectId').mockReturnValue(Promise.resolve());
mediator.setMoveToProjectId(projectId);
expect(spy).toHaveBeenCalledWith(projectId);
spy.mockRestore();
});
it('fetches autocomplete projects', () => {
const searchTerm = 'foo';
mock.onGet(mediatorMockData.projectsAutocompleteEndpoint).reply(200, {});
const getterSpy = jest
.spyOn(mediator.service, 'getProjectsAutocomplete')
.mockReturnValue(Promise.resolve({ data: {} }));
const setterSpy = jest
.spyOn(mediator.store, 'setAutocompleteProjects')
.mockReturnValue(Promise.resolve());
return mediator.fetchAutocompleteProjects(searchTerm).then(() => {
expect(getterSpy).toHaveBeenCalledWith(searchTerm);
expect(setterSpy).toHaveBeenCalled();
getterSpy.mockRestore();
setterSpy.mockRestore();
});
});
it('moves issue', () => {
const mockData = Mock.responseMap.POST[mediatorMockData.moveIssueEndpoint];
const moveToProjectId = 7;
mock.onPost(mediatorMockData.moveIssueEndpoint).reply(200, mockData);
mediator.store.setMoveToProjectId(moveToProjectId);
const moveIssueSpy = jest
.spyOn(mediator.service, 'moveIssue')
.mockReturnValue(Promise.resolve({ data: { web_url: mockData.web_url } }));
const urlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
return mediator.moveIssue().then(() => {
expect(moveIssueSpy).toHaveBeenCalledWith(moveToProjectId);
expect(urlSpy).toHaveBeenCalledWith(mockData.web_url);
moveIssueSpy.mockRestore();
urlSpy.mockRestore();
});
});
it('toggle subscription', () => {
mediator.store.setSubscribedState(false);
mock.onPost(mediatorMockData.toggleSubscriptionEndpoint).reply(200, {});
const spy = jest
.spyOn(mediator.service, 'toggleSubscription')
.mockReturnValue(Promise.resolve());
return mediator.toggleSubscription().then(() => {
expect(spy).toHaveBeenCalled();
expect(mediator.store.subscribed).toEqual(true);
spy.mockRestore();
});
});
});

View file

@ -0,0 +1,36 @@
import { shallowMount } from '@vue/test-utils';
import SidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import Mock from './mock_data';
describe('Sidebar Subscriptions', () => {
let wrapper;
let mediator;
beforeEach(() => {
mediator = new SidebarMediator(Mock.mediator);
wrapper = shallowMount(SidebarSubscriptions, {
propsData: {
mediator,
},
});
});
afterEach(() => {
wrapper.destroy();
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
});
it('calls the mediator toggleSubscription on event', () => {
const spy = jest.spyOn(mediator, 'toggleSubscription').mockReturnValue(Promise.resolve());
wrapper.vm.onToggleSubscription();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});

View file

@ -0,0 +1,106 @@
import { shallowMount } from '@vue/test-utils';
import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
import eventHub from '~/sidebar/event_hub';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
describe('Subscriptions', () => {
let wrapper;
const findToggleButton = () => wrapper.find(ToggleButton);
const mountComponent = propsData =>
shallowMount(Subscriptions, {
propsData,
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('shows loading spinner when loading', () => {
wrapper = mountComponent({
loading: true,
subscribed: undefined,
});
expect(findToggleButton().attributes('isloading')).toBe('true');
});
it('is toggled "off" when currently not subscribed', () => {
wrapper = mountComponent({
subscribed: false,
});
expect(findToggleButton().attributes('value')).toBeFalsy();
});
it('is toggled "on" when currently subscribed', () => {
wrapper = mountComponent({
subscribed: true,
});
expect(findToggleButton().attributes('value')).toBe('true');
});
it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => {
const id = 42;
wrapper = mountComponent({ subscribed: true, id });
const eventHubSpy = jest.spyOn(eventHub, '$emit');
const wrapperEmitSpy = jest.spyOn(wrapper.vm, '$emit');
wrapper.vm.toggleSubscription();
expect(eventHubSpy).toHaveBeenCalledWith('toggleSubscription', id);
expect(wrapperEmitSpy).toHaveBeenCalledWith('toggleSubscription', id);
eventHubSpy.mockRestore();
wrapperEmitSpy.mockRestore();
});
it('tracks the event when toggled', () => {
wrapper = mountComponent({ subscribed: true });
const wrapperTrackSpy = jest.spyOn(wrapper.vm, 'track');
wrapper.vm.toggleSubscription();
expect(wrapperTrackSpy).toHaveBeenCalledWith('toggle_button', {
property: 'notifications',
value: 0,
});
wrapperTrackSpy.mockRestore();
});
it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => {
wrapper = mountComponent({ subscribed: true });
const spy = jest.spyOn(wrapper.vm, '$emit');
wrapper.vm.onClickCollapsedIcon();
expect(spy).toHaveBeenCalledWith('toggleSidebar');
spy.mockRestore();
});
describe('given project emails are disabled', () => {
const subscribeDisabledDescription = 'Notifications have been disabled';
beforeEach(() => {
wrapper = mountComponent({
subscribed: false,
projectEmailsDisabled: true,
subscribeDisabledDescription,
});
});
it('sets the correct display text', () => {
expect(wrapper.find('.issuable-header-text').text()).toContain(subscribeDisabledDescription);
expect(wrapper.find({ ref: 'tooltip' }).attributes('data-original-title')).toBe(
subscribeDisabledDescription,
);
});
it('does not render the toggle button', () => {
expect(wrapper.contains('.js-issuable-subscribe-button')).toBe(false);
});
});
});

View file

@ -0,0 +1,37 @@
import { shallowMount } from '@vue/test-utils';
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
describe('Rich Content Editor', () => {
let wrapper;
const value = '## Some Markdown';
const findEditor = () => wrapper.find({ ref: 'editor' });
beforeEach(() => {
wrapper = shallowMount(RichContentEditor, {
propsData: { value },
});
});
describe('when content is loaded', () => {
it('renders an editor', () => {
expect(findEditor().exists()).toBe(true);
});
it('renders the correct content', () => {
expect(findEditor().props().initialValue).toBe(value);
});
});
describe('when content is changed', () => {
it('emits an input event with the changed content', () => {
const changedMarkdown = '## Changed Markdown';
const getMarkdownMock = jest.fn().mockReturnValueOnce(changedMarkdown);
findEditor().setMethods({ invoke: getMarkdownMock });
findEditor().vm.$emit('change');
expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown);
});
});
});

View file

@ -112,7 +112,6 @@ describe SearchHelper do
'milestones' | 'milestone'
'notes' | 'comment'
'projects' | 'project'
'snippet_blobs' | 'snippet result'
'snippet_titles' | 'snippet'
'users' | 'user'
'wiki_blobs' | 'wiki result'

View file

@ -1,32 +0,0 @@
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import editFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue';
describe('EditFormButtons', () => {
let vm1;
let vm2;
beforeEach(() => {
const Component = Vue.extend(editFormButtons);
const toggleForm = () => {};
const updateLockedAttribute = () => {};
vm1 = mountComponent(Component, {
isLocked: true,
toggleForm,
updateLockedAttribute,
});
vm2 = mountComponent(Component, {
isLocked: false,
toggleForm,
updateLockedAttribute,
});
});
it('renders unlock or lock text based on locked state', () => {
expect(vm1.$el.innerHTML.includes('Unlock')).toBe(true);
expect(vm2.$el.innerHTML.includes('Lock')).toBe(true);
});
});

View file

@ -1,202 +0,0 @@
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import participants from '~/sidebar/components/participants/participants.vue';
const PARTICIPANT = {
id: 1,
state: 'active',
username: 'marcene',
name: 'Allie Will',
web_url: 'foo.com',
avatar_url: 'gravatar.com/avatar/xxx',
};
const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }];
describe('Participants', function() {
let vm;
let Participants;
beforeEach(() => {
Participants = Vue.extend(participants);
});
afterEach(() => {
vm.$destroy();
});
describe('collapsed sidebar state', () => {
it('shows loading spinner when loading', () => {
vm = mountComponent(Participants, {
loading: true,
});
expect(vm.$el.querySelector('.js-participants-collapsed-loading-icon')).toBeDefined();
});
it('shows participant count when given', () => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
});
const countEl = vm.$el.querySelector('.js-participants-collapsed-count');
expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`);
});
it('shows full participant count when there are hidden participants', () => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 1,
});
const countEl = vm.$el.querySelector('.js-participants-collapsed-count');
expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`);
});
});
describe('expanded sidebar state', () => {
it('shows loading spinner when loading', () => {
vm = mountComponent(Participants, {
loading: true,
});
expect(vm.$el.querySelector('.js-participants-expanded-loading-icon')).toBeDefined();
});
it('when only showing visible participants, shows an avatar only for each participant under the limit', done => {
const numberOfLessParticipants = 2;
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants,
});
vm.isShowingMoreParticipants = false;
Vue.nextTick()
.then(() => {
const participantEls = vm.$el.querySelectorAll('.js-participants-author');
expect(participantEls.length).toBe(numberOfLessParticipants);
})
.then(done)
.catch(done.fail);
});
it('when only showing all participants, each has an avatar', done => {
const numberOfLessParticipants = 2;
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants,
});
vm.isShowingMoreParticipants = true;
Vue.nextTick()
.then(() => {
const participantEls = vm.$el.querySelectorAll('.js-participants-author');
expect(participantEls.length).toBe(PARTICIPANT_LIST.length);
})
.then(done)
.catch(done.fail);
});
it('does not have more participants link when they can all be shown', () => {
const numberOfLessParticipants = 100;
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants,
});
const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants);
expect(moreParticipantLink).toBeNull();
});
it('when too many participants, has more participants link to show more', done => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
vm.isShowingMoreParticipants = false;
Vue.nextTick()
.then(() => {
const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
expect(moreParticipantLink.textContent.trim()).toBe('+ 1 more');
})
.then(done)
.catch(done.fail);
});
it('when too many participants and already showing them, has more participants link to show less', done => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
vm.isShowingMoreParticipants = true;
Vue.nextTick()
.then(() => {
const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
expect(moreParticipantLink.textContent.trim()).toBe('- show less');
})
.then(done)
.catch(done.fail);
});
it('clicking more participants link emits event', () => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button');
expect(vm.isShowingMoreParticipants).toBe(false);
moreParticipantLink.click();
expect(vm.isShowingMoreParticipants).toBe(true);
});
it('clicking on participants icon emits `toggleSidebar` event', () => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
spyOn(vm, '$emit');
const participantsIconEl = vm.$el.querySelector('.sidebar-collapsed-icon');
participantsIconEl.click();
expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar');
});
});
describe('when not showing participants label', () => {
beforeEach(() => {
vm = mountComponent(Participants, {
participants: PARTICIPANT_LIST,
showParticipantLabel: false,
});
});
it('does not show sidebar collapsed icon', () => {
expect(vm.$el.querySelector('.sidebar-collapsed-icon')).not.toBeTruthy();
});
it('does not show participants label title', () => {
expect(vm.$el.querySelector('.title')).not.toBeTruthy();
});
});
});

View file

@ -1,134 +0,0 @@
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service';
import Mock from './mock_data';
const { mediator: mediatorMockData } = Mock;
describe('Sidebar mediator', function() {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
this.mediator = new SidebarMediator(mediatorMockData);
});
afterEach(() => {
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
mock.restore();
});
it('assigns yourself ', () => {
this.mediator.assignYourself();
expect(this.mediator.store.currentUser).toEqual(mediatorMockData.currentUser);
expect(this.mediator.store.assignees[0]).toEqual(mediatorMockData.currentUser);
});
it('saves assignees', done => {
mock.onPut(mediatorMockData.endpoint).reply(200, {});
this.mediator
.saveAssignees('issue[assignee_ids]')
.then(resp => {
expect(resp.status).toEqual(200);
done();
})
.catch(done.fail);
});
it('fetches the data', done => {
const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
mock.onGet(mediatorMockData.endpoint).reply(200, mockData);
const mockGraphQlData = Mock.graphQlResponseData;
spyOn(gqClient, 'query').and.returnValue({
data: mockGraphQlData,
});
spyOn(this.mediator, 'processFetchedData').and.callThrough();
this.mediator
.fetch()
.then(() => {
expect(this.mediator.processFetchedData).toHaveBeenCalledWith(mockData, mockGraphQlData);
})
.then(done)
.catch(done.fail);
});
it('processes fetched data', () => {
const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
this.mediator.processFetchedData(mockData);
expect(this.mediator.store.assignees).toEqual(mockData.assignees);
expect(this.mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate);
expect(this.mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent);
expect(this.mediator.store.participants).toEqual(mockData.participants);
expect(this.mediator.store.subscribed).toEqual(mockData.subscribed);
expect(this.mediator.store.timeEstimate).toEqual(mockData.time_estimate);
expect(this.mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent);
});
it('sets moveToProjectId', () => {
const projectId = 7;
spyOn(this.mediator.store, 'setMoveToProjectId').and.callThrough();
this.mediator.setMoveToProjectId(projectId);
expect(this.mediator.store.setMoveToProjectId).toHaveBeenCalledWith(projectId);
});
it('fetches autocomplete projects', done => {
const searchTerm = 'foo';
mock.onGet(mediatorMockData.projectsAutocompleteEndpoint).reply(200, {});
spyOn(this.mediator.service, 'getProjectsAutocomplete').and.callThrough();
spyOn(this.mediator.store, 'setAutocompleteProjects').and.callThrough();
this.mediator
.fetchAutocompleteProjects(searchTerm)
.then(() => {
expect(this.mediator.service.getProjectsAutocomplete).toHaveBeenCalledWith(searchTerm);
expect(this.mediator.store.setAutocompleteProjects).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('moves issue', done => {
const mockData = Mock.responseMap.POST[mediatorMockData.moveIssueEndpoint];
const moveToProjectId = 7;
mock.onPost(mediatorMockData.moveIssueEndpoint).reply(200, mockData);
this.mediator.store.setMoveToProjectId(moveToProjectId);
spyOn(this.mediator.service, 'moveIssue').and.callThrough();
const visitUrl = spyOnDependency(SidebarMediator, 'visitUrl');
this.mediator
.moveIssue()
.then(() => {
expect(this.mediator.service.moveIssue).toHaveBeenCalledWith(moveToProjectId);
expect(visitUrl).toHaveBeenCalledWith(mockData.web_url);
})
.then(done)
.catch(done.fail);
});
it('toggle subscription', done => {
this.mediator.store.setSubscribedState(false);
mock.onPost(mediatorMockData.toggleSubscriptionEndpoint).reply(200, {});
spyOn(this.mediator.service, 'toggleSubscription').and.callThrough();
this.mediator
.toggleSubscription()
.then(() => {
expect(this.mediator.service.toggleSubscription).toHaveBeenCalled();
expect(this.mediator.store.subscribed).toEqual(true);
})
.then(done)
.catch(done.fail);
});
});

View file

@ -1,38 +0,0 @@
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import sidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import Mock from './mock_data';
describe('Sidebar Subscriptions', function() {
let vm;
let SidebarSubscriptions;
beforeEach(() => {
SidebarSubscriptions = Vue.extend(sidebarSubscriptions);
// Set up the stores, services, etc
// eslint-disable-next-line no-new
new SidebarMediator(Mock.mediator);
});
afterEach(() => {
vm.$destroy();
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
});
it('calls the mediator toggleSubscription on event', () => {
const mediator = new SidebarMediator();
spyOn(mediator, 'toggleSubscription').and.returnValue(Promise.resolve());
vm = mountComponent(SidebarSubscriptions, {
mediator,
});
vm.onToggleSubscription();
expect(mediator.toggleSubscription).toHaveBeenCalled();
});
});

View file

@ -1,100 +0,0 @@
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTracking } from 'spec/helpers/tracking_helper';
import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
import eventHub from '~/sidebar/event_hub';
describe('Subscriptions', function() {
let vm;
let Subscriptions;
beforeEach(() => {
Subscriptions = Vue.extend(subscriptions);
});
afterEach(() => {
vm.$destroy();
});
it('shows loading spinner when loading', () => {
vm = mountComponent(Subscriptions, {
loading: true,
subscribed: undefined,
});
expect(vm.$refs.toggleButton.isLoading).toBe(true);
expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass(
'is-loading',
);
});
it('is toggled "off" when currently not subscribed', () => {
vm = mountComponent(Subscriptions, {
subscribed: false,
});
expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).not.toHaveClass(
'is-checked',
);
});
it('is toggled "on" when currently subscribed', () => {
vm = mountComponent(Subscriptions, {
subscribed: true,
});
expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass(
'is-checked',
);
});
it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => {
vm = mountComponent(Subscriptions, { subscribed: true });
spyOn(eventHub, '$emit');
spyOn(vm, '$emit');
spyOn(vm, 'track');
vm.toggleSubscription();
expect(eventHub.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object));
expect(vm.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object));
});
it('tracks the event when toggled', () => {
vm = mountComponent(Subscriptions, { subscribed: true });
const spy = mockTracking('_category_', vm.$el, spyOn);
vm.toggleSubscription();
expect(spy).toHaveBeenCalled();
});
it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => {
vm = mountComponent(Subscriptions, { subscribed: true });
spyOn(vm, '$emit');
vm.onClickCollapsedIcon();
expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar');
});
describe('given project emails are disabled', () => {
const subscribeDisabledDescription = 'Notifications have been disabled';
beforeEach(() => {
vm = mountComponent(Subscriptions, {
subscribed: false,
projectEmailsDisabled: true,
subscribeDisabledDescription,
});
});
it('sets the correct display text', () => {
expect(vm.$el.textContent).toContain(subscribeDisabledDescription);
expect(vm.$refs.tooltip.dataset.originalTitle).toBe(subscribeDisabledDescription);
});
it('does not render the toggle button', () => {
expect(vm.$refs.toggleButton).toBeUndefined();
});
});
});

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::BackgroundMigration::BackfillEnvironmentIdDeploymentMergeRequests, schema: 20200312134637 do
let(:environments) { table(:environments) }
let(:merge_requests) { table(:merge_requests) }
let(:deployments) { table(:deployments) }
let(:deployment_merge_requests) { table(:deployment_merge_requests) }
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
subject(:migration) { described_class.new }
it 'correctly backfills environment_id column' do
namespace = namespaces.create!(name: 'foo', path: 'foo')
project = projects.create!(namespace_id: namespace.id)
production = environments.create!(project_id: project.id, name: 'production', slug: 'production')
staging = environments.create!(project_id: project.id, name: 'staging', slug: 'staging')
mr = merge_requests.create!(source_branch: 'x', target_branch: 'master', target_project_id: project.id)
deployment1 = deployments.create!(environment_id: staging.id, iid: 1, project_id: project.id, ref: 'master', tag: false, sha: '123abcdef', status: 1)
deployment2 = deployments.create!(environment_id: production.id, iid: 2, project_id: project.id, ref: 'master', tag: false, sha: '123abcdef', status: 1)
deployment3 = deployments.create!(environment_id: production.id, iid: 3, project_id: project.id, ref: 'master', tag: false, sha: '123abcdef', status: 1)
# mr is tracked twice in production through deployment2 and deployment3
deployment_merge_requests.create!(deployment_id: deployment1.id, merge_request_id: mr.id)
deployment_merge_requests.create!(deployment_id: deployment2.id, merge_request_id: mr.id)
deployment_merge_requests.create!(deployment_id: deployment3.id, merge_request_id: mr.id)
expect(deployment_merge_requests.where(environment_id: nil).count).to eq(3)
migration.perform(1, mr.id)
expect(deployment_merge_requests.where(environment_id: nil).count).to be_zero
expect(deployment_merge_requests.count).to eq(2)
production_deployments = deployment_merge_requests.where(environment_id: production.id)
expect(production_deployments.count).to eq(1)
expect(production_deployments.first.deployment_id).to eq(deployment2.id)
expect(deployment_merge_requests.where(environment_id: staging.id).count).to eq(1)
end
end

View file

@ -86,14 +86,6 @@ describe Gitlab::Danger::Changelog do
end
end
describe '#presented_no_changelog_labels' do
subject { changelog.presented_no_changelog_labels }
it 'returns the labels formatted' do
is_expected.to eq('~backstage, ~ci-build, ~meta')
end
end
describe '#ee_changelog?' do
subject { changelog.ee_changelog? }

View file

@ -399,9 +399,28 @@ describe Gitlab::Danger::Helper do
end
end
describe '#labels_list' do
let(:labels) { ['telemetry', 'telemetry::reviewed'] }
it 'composes the labels string' do
expect(helper.labels_list(labels)).to eq('~"telemetry", ~"telemetry::reviewed"')
end
context 'when passing a separator' do
it 'composes the labels string with the given separator' do
expect(helper.labels_list(labels, sep: ' ')).to eq('~"telemetry" ~"telemetry::reviewed"')
end
end
it 'returns empty string for empty array' do
expect(helper.labels_list([])).to eq('')
end
end
describe '#prepare_labels_for_mr' do
it 'composes the labels string' do
mr_labels = ['telemetry', 'telemetry::reviewed']
expect(helper.prepare_labels_for_mr(mr_labels)).to eq('/label ~"telemetry" ~"telemetry::reviewed"')
end

View file

@ -0,0 +1,81 @@
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20200312134637_backfill_environment_id_on_deployment_merge_requests.rb')
describe BackfillEnvironmentIdOnDeploymentMergeRequests do
let(:environments) { table(:environments) }
let(:merge_requests) { table(:merge_requests) }
let(:deployments) { table(:deployments) }
let(:deployment_merge_requests) { table(:deployment_merge_requests) }
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:migration_worker) { double('BackgroundMigrationWorker') }
before do
stub_const('BackgroundMigrationWorker', migration_worker)
end
it 'schedules nothing when there are no entries' do
expect(migration_worker).not_to receive(:perform_in)
migrate!
end
it 'batches the workload' do
stub_const("#{described_class.name}::BATCH_SIZE", 10)
namespace = namespaces.create!(name: 'foo', path: 'foo')
project = projects.create!(namespace_id: namespace.id)
environment = environments.create!(project_id: project.id, name: 'staging', slug: 'staging')
# Batching is based on DeploymentMergeRequest.merge_request_id, in order to test it
# we must generate more than described_class::BATCH_SIZE merge requests, deployments,
# and deployment_merge_requests entries
entries = 13
expect(entries).to be > described_class::BATCH_SIZE
# merge requests and deployments bulk generation
mrs_params = []
deployments_params = []
entries.times do |i|
mrs_params << { source_branch: 'x', target_branch: 'master', target_project_id: project.id }
deployments_params << { environment_id: environment.id, iid: i + 1, project_id: project.id, ref: 'master', tag: false, sha: '123abcdef', status: 1 }
end
all_mrs = merge_requests.insert_all(mrs_params)
all_deployments = deployments.insert_all(deployments_params)
# deployment_merge_requests bulk generation
dmr_params = []
entries.times do |index|
mr_id = all_mrs.rows[index].first
deployment_id = all_deployments.rows[index].first
dmr_params << { deployment_id: deployment_id, merge_request_id: mr_id }
end
deployment_merge_requests.insert_all(dmr_params)
first_batch_limit = dmr_params[described_class::BATCH_SIZE][:merge_request_id]
second_batch_limit = dmr_params.last[:merge_request_id]
expect(migration_worker).to receive(:perform_in)
.with(
0,
'BackfillEnvironmentIdDeploymentMergeRequests',
[1, first_batch_limit]
)
expect(migration_worker).to receive(:perform_in)
.with(
described_class::DELAY,
'BackfillEnvironmentIdDeploymentMergeRequests',
[first_batch_limit + 1, second_batch_limit]
)
migrate!
end
end

View file

@ -3700,41 +3700,41 @@ describe MergeRequest do
describe '#recent_visible_deployments' do
let(:merge_request) { create(:merge_request) }
let(:environment) do
create(:environment, project: merge_request.target_project)
end
it 'returns visible deployments' do
envs = create_list(:environment, 3, project: merge_request.target_project)
created = create(
:deployment,
:created,
project: merge_request.target_project,
environment: environment
environment: envs[0]
)
success = create(
:deployment,
:success,
project: merge_request.target_project,
environment: environment
environment: envs[1]
)
failed = create(
:deployment,
:failed,
project: merge_request.target_project,
environment: environment
environment: envs[2]
)
merge_request.deployment_merge_requests.create!(deployment: created)
merge_request.deployment_merge_requests.create!(deployment: success)
merge_request.deployment_merge_requests.create!(deployment: failed)
merge_request_relation = MergeRequest.where(id: merge_request.id)
created.link_merge_requests(merge_request_relation)
success.link_merge_requests(merge_request_relation)
failed.link_merge_requests(merge_request_relation)
expect(merge_request.recent_visible_deployments).to eq([failed, success])
end
it 'only returns a limited number of deployments' do
20.times do
environment = create(:environment, project: merge_request.target_project)
deploy = create(
:deployment,
:success,
@ -3742,7 +3742,7 @@ describe MergeRequest do
environment: environment
)
merge_request.deployment_merge_requests.create!(deployment: deploy)
deploy.link_merge_requests(MergeRequest.where(id: merge_request.id))
end
expect(merge_request.recent_visible_deployments.count).to eq(10)

View file

@ -538,18 +538,6 @@ describe User, :do_not_mock_admin_mode do
expect(user).to be_valid
end
context 'when feature flag is turned off' do
before do
stub_feature_flags(email_restrictions: false)
end
it 'does accept the email address' do
user = build(:user, email: 'info+1@test.com')
expect(user).to be_valid
end
end
context 'when created_by_id is set' do
it 'does accept the email address' do
user = build(:user, email: 'info+1@test.com', created_by_id: 1)

View file

@ -439,7 +439,7 @@ describe API::Deployments do
let!(:merge_request3) { create(:merge_request, source_project: project2, target_project: project2) }
it 'returns the relevant merge requests linked to a deployment for a project' do
deployment.merge_requests << [merge_request1, merge_request2]
deployment.link_merge_requests(MergeRequest.where(id: [merge_request1.id, merge_request2.id]))
subject

View file

@ -3,26 +3,26 @@
require 'spec_helper'
describe API::Issues do
let_it_be(:user) { create(:user) }
let(:user2) { create(:user) }
let(:non_member) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:author) { create(:author) }
let_it_be(:assignee) { create(:assignee) }
let(:admin) { create(:user, :admin) }
let(:issue_title) { 'foo' }
let(:issue_description) { 'closed' }
let(:no_milestone_title) { 'None' }
let(:any_milestone_title) { 'Any' }
let_it_be(:user2) { create(:user) }
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:non_member) { create(:user) }
let_it_be(:user) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:author) { create(:author) }
let_it_be(:assignee) { create(:assignee) }
let_it_be(:issue_title) { 'foo' }
let_it_be(:issue_description) { 'closed' }
let_it_be(:no_milestone_title) { 'None' }
let_it_be(:any_milestone_title) { 'Any' }
before do
stub_licensed_features(multiple_issue_assignees: false, issue_weights: false)
end
describe 'GET /groups/:id/issues' do
let!(:group) { create(:group) }
let!(:group_project) { create(:project, :public, :repository, creator_id: user.id, namespace: group) }
let!(:private_mrs_project) do
let_it_be(:group) { create(:group) }
let_it_be(:group_project) { create(:project, :public, :repository, creator_id: user.id, namespace: group) }
let_it_be(:private_mrs_project) do
create(:project, :public, :repository, creator_id: user.id, namespace: group, merge_requests_access_level: ProjectFeature::PRIVATE)
end
@ -455,6 +455,29 @@ describe API::Issues do
it_behaves_like 'labeled issues with labels and label_name params'
end
context 'with archived projects' do
let_it_be(:archived_issue) do
create(
:issue, author: user, assignees: [user],
project: create(:project, :public, :archived, creator_id: user.id, namespace: group)
)
end
it 'returns only non archived projects issues' do
get api(base_url, user)
expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id])
end
it 'returns issues from archived projects if non_archived it set to false' do
get api(base_url, user), params: { non_archived: false }
expect_paginated_array_response(
[archived_issue.id, group_closed_issue.id, group_confidential_issue.id, group_issue.id]
)
end
end
it 'returns an array of issues found by iids' do
get api(base_url, user), params: { iids: [group_issue.iid] }

View file

@ -780,28 +780,20 @@ describe API::Issues do
end
context 'filtering by non_archived' do
let_it_be(:group1) { create(:group) }
let_it_be(:archived_project) { create(:project, :archived, namespace: group1) }
let_it_be(:active_project) { create(:project, namespace: group1) }
let_it_be(:issue1) { create(:issue, project: active_project) }
let_it_be(:issue2) { create(:issue, project: active_project) }
let_it_be(:issue3) { create(:issue, project: archived_project) }
let_it_be(:archived_project) { create(:project, :archived, creator_id: user.id, namespace: user.namespace) }
let_it_be(:archived_issue) { create(:issue, author: user, project: archived_project) }
let_it_be(:active_issue) { create(:issue, author: user, project: project) }
before do
archived_project.add_developer(user)
active_project.add_developer(user)
it 'returns issues from non archived projects by default' do
get api('/issues', user)
expect_paginated_array_response(active_issue.id, issue.id, closed_issue.id)
end
it 'returns issues from non archived projects only by default' do
get api("/groups/#{group1.id}/issues", user), params: { scope: 'all' }
it 'returns issues from archived project with non_archived set as false' do
get api("/issues", user), params: { non_archived: false }
expect_paginated_array_response([issue2.id, issue1.id])
end
it 'returns issues from archived and non archived projects when non_archived is false' do
get api("/groups/#{group1.id}/issues", user), params: { non_archived: false, scope: 'all' }
expect_paginated_array_response([issue3.id, issue2.id, issue1.id])
expect_paginated_array_response(active_issue.id, archived_issue.id, issue.id, closed_issue.id)
end
end
end

View file

@ -129,16 +129,6 @@ describe API::Search do
it_behaves_like 'response is correct', schema: 'public_api/v4/snippets'
end
context 'for snippet_blobs scope' do
before do
create(:snippet, :public, title: 'awesome snippet', content: 'snippet content')
get api('/search', user), params: { scope: 'snippet_blobs', search: 'content' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/snippets'
end
end
end

View file

@ -33,5 +33,11 @@ describe RuboCop::Cop::Migration::AddConcurrentForeignKey do
expect(cop.offenses.map(&:line)).to eq([1])
end
end
it 'does not register an offense when a `NOT VALID` foreign key is added' do
inspect_source('def up; add_foreign_key(:projects, :users, column: :user_id, validate: false); end')
expect(cop.offenses).to be_empty
end
end
end

View file

@ -110,6 +110,31 @@ describe Issues::CreateService do
end
end
context 'when labels is nil' do
let(:opts) do
{ title: 'Title',
description: 'Description',
labels: nil }
end
it 'does not assign label' do
expect(issue.labels).to be_empty
end
end
context 'when labels is nil and label_ids is present' do
let(:opts) do
{ title: 'Title',
description: 'Description',
labels: nil,
label_ids: labels.map(&:id) }
end
it 'assigns group labels' do
expect(issue.labels).to match_array labels
end
end
context 'when milestone belongs to different project' do
let(:milestone) { create(:milestone) }

View file

@ -1041,6 +1041,21 @@
resolved "https://registry.yarnpkg.com/@sourcegraph/code-host-integration/-/code-host-integration-0.0.37.tgz#87f9a602e2a60520b6038311a67face2ece86827"
integrity sha512-GQvNuPORLjsMhto57Ue1umeSV3cir+hMEaGxwCKmmq+cc9ZSZpuXa8RVBXuT5azN99K9/8zFps4woyPJ8wrjYA==
"@toast-ui/editor@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@toast-ui/editor/-/editor-2.0.1.tgz#749e5be1f02f42ded51488d1575ab1c19ca59952"
integrity sha512-TC481O/zP37boY6H6oVN6KLVMY7yrU8zQu+3xqZ71V3Sr6D2XyaGb2Xub9XqTdqzBmzsf7y4Gi+EXO0IQ3rGVA==
dependencies:
"@types/codemirror" "0.0.71"
codemirror "^5.48.4"
"@toast-ui/vue-editor@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@toast-ui/vue-editor/-/vue-editor-2.0.1.tgz#c9c8c8da4c0a67b9fbc4240464388c67d72a0c22"
integrity sha512-sGsApl0n+GVAZbmPA+tTrq9rmmyh2mRgCgg2/mu1/lN7S4vPv/nQH8KXxLG9Y6hG2+kgelqz6wvbOCdzlM/HmQ==
dependencies:
"@toast-ui/editor" "^2.0.1"
"@types/anymatch@*":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.0.tgz#d1d55958d1fccc5527d4aba29fc9c4b942f563ff"
@ -1079,6 +1094,18 @@
dependencies:
"@babel/types" "^7.3.0"
"@types/codemirror@0.0.71":
version "0.0.71"
resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-0.0.71.tgz#861f1bcb3100c0a064567c5400f2981cf4ae8ca7"
integrity sha512-b2oEEnno1LIGKMR7uBEsr40al1UijF1HEpRn0+Yf1xOLl24iQgB7DBpZVMM7y54G5wCNoclDrRO65E6KHPNO2w==
dependencies:
"@types/tern" "*"
"@types/estree@*":
version "0.0.44"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.44.tgz#980cc5a29a3ef3bea6ff1f7d021047d7ea575e21"
integrity sha512-iaIVzr+w2ZJ5HkidlZ3EJM8VTZb2MJLCjw3V+505yVts0gRC4UMvjw0d1HPtGqI/HQC/KdsYtayfzl+AXY2R8g==
"@types/events@*":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86"
@ -1138,6 +1165,13 @@
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.4.tgz#b4ffc7dc97b498c969b360a41eee247f82616370"
integrity sha512-78AdXtlhpCHT0K3EytMpn4JNxaf5tbqbLcbIRoQIHzpTIyjpxLQKRoxU55ujBXAtg3Nl2h/XWvfDa9dsMOd0pQ==
"@types/tern@*":
version "0.23.3"
resolved "https://registry.yarnpkg.com/@types/tern/-/tern-0.23.3.tgz#4b54538f04a88c9ff79de1f6f94f575a7f339460"
integrity sha512-imDtS4TAoTcXk0g7u4kkWqedB3E4qpjXzCpD2LU5M5NAXHzCDsypyvXSaG7mM8DKYkCRa7tFp4tS/lp/Wo7Q3w==
dependencies:
"@types/estree" "*"
"@types/uglify-js@*":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
@ -2764,6 +2798,11 @@ code-point-at@^1.0.0:
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
codemirror@^5.48.4:
version "5.53.2"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.53.2.tgz#9799121cf8c50809cca487304e9de3a74d33f428"
integrity sha512-wvSQKS4E+P8Fxn/AQ+tQtJnF1qH5UOlxtugFLpubEZ5jcdH2iXTVinb+Xc/4QjshuOxRm4fUsU2QPF1JJKiyXA==
codesandbox-api@0.0.23:
version "0.0.23"
resolved "https://registry.yarnpkg.com/codesandbox-api/-/codesandbox-api-0.0.23.tgz#bf650a21b5f3c2369e03f0c19d10b4e2ba255b4f"