提交 6d31b8f0 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 69944ffb
......@@ -113,10 +113,9 @@ const Api = {
.get(url, {
params: Object.assign(defaults, options),
})
.then(({ data }) => {
.then(({ data, headers }) => {
callback(data);
return data;
return { data, headers };
});
},
......
......@@ -47,7 +47,8 @@ export default {
hasSearchQuery: true,
});
},
[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) {
[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) {
const rawItems = results.data;
Object.assign(state, {
items: rawItems.map(rawItem => ({
id: rawItem.id,
......
......@@ -11,11 +11,35 @@ export default {
computed: {
...mapState(['traceEndpoint', 'trace', 'isTraceComplete']),
},
updated() {
this.$nextTick(() => {
this.handleScrollDown();
});
},
mounted() {
this.$nextTick(() => {
this.handleScrollDown();
});
},
methods: {
...mapActions(['toggleCollapsibleLine']),
...mapActions(['toggleCollapsibleLine', 'scrollBottom']),
handleOnClickCollapsibleLine(section) {
this.toggleCollapsibleLine(section);
},
/**
* The job log is sent in HTML, which means we need to use `v-html` to render it
* Using the updated hook with $nextTick is not enough to wait for the DOM to be updated
* in this case because it runs before `v-html` has finished running, since there's no
* Vue binding.
* In order to scroll the page down after `v-html` has finished, we need to use setTimeout
*/
handleScrollDown() {
if (this.isScrolledToBottomBeforeReceivingTrace) {
setTimeout(() => {
this.scrollBottom();
}, 0);
}
},
},
};
</script>
......
<script>
import { mapActions } from 'vuex';
import _ from 'underscore';
import { s__, __, sprintf } from '~/locale';
import { truncateSha } from '~/lib/utils/text_utility';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import noteEditedText from './note_edited_text.vue';
import noteHeader from './note_header.vue';
export default {
name: 'DiffDiscussionHeader',
components: {
userAvatarLink,
noteEditedText,
noteHeader,
},
props: {
discussion: {
type: Object,
required: true,
},
},
computed: {
notes() {
return this.discussion.notes;
},
firstNote() {
return this.notes[0];
},
lastNote() {
return this.notes[this.notes.length - 1];
},
author() {
return this.firstNote.author;
},
resolvedText() {
return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
},
lastUpdatedBy() {
return this.notes.length > 1 ? this.lastNote.author : null;
},
lastUpdatedAt() {
return this.notes.length > 1 ? this.lastNote.created_at : null;
},
headerText() {
const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`;
const linkEnd = '</a>';
const { commit_id: commitId } = this.discussion;
let commitDisplay = commitId;
if (commitId) {
commitDisplay = `<span class="commit-sha">${truncateSha(commitId)}</span>`;
}
const {
for_commit: isForCommit,
diff_discussion: isDiffDiscussion,
active: isActive,
} = this.discussion;
let text = s__('MergeRequests|started a thread');
if (isForCommit) {
text = s__(
'MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}',
);
} else if (isDiffDiscussion && commitId) {
text = isActive
? s__('MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}')
: s__(
'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitDisplay}%{linkEnd}',
);
} else if (isDiffDiscussion) {
text = isActive
? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}')
: s__(
'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}',
);
}
return sprintf(text, { commitDisplay, linkStart, linkEnd }, false);
},
},
methods: {
...mapActions(['toggleDiscussion']),
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.discussion.id });
},
},
};
</script>
<template>
<div class="discussion-header note-wrapper">
<div v-once class="timeline-icon align-self-start flex-shrink-0">
<user-avatar-link
v-if="author"
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
/>
</div>
<div class="timeline-content w-100">
<note-header
:author="author"
:created-at="firstNote.created_at"
:note-id="firstNote.id"
:include-toggle="true"
:expanded="discussion.expanded"
@toggleHandler="toggleDiscussionHandler"
>
<span v-html="headerText"></span>
</note-header>
<note-edited-text
v-if="discussion.resolved"
:edited-at="discussion.resolved_at"
:edited-by="discussion.resolved_by"
:action-text="resolvedText"
class-name="discussion-headline-light js-discussion-headline"
/>
<note-edited-text
v-else-if="lastUpdatedAt"
:edited-at="lastUpdatedAt"
:edited-by="lastUpdatedBy"
:action-text="__('Last updated')"
class-name="discussion-headline-light js-discussion-headline"
/>
</div>
</div>
</template>
<script>
import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
import { s__, __ } from '~/locale';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import icon from '~/vue_shared/components/icon.vue';
import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import noteHeader from './note_header.vue';
import diffDiscussionHeader from './diff_discussion_header.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue';
import noteable from '../mixins/noteable';
......@@ -27,9 +24,8 @@ export default {
components: {
icon,
userAvatarLink,
noteHeader,
diffDiscussionHeader,
noteSignedOutWidget,
noteEditedText,
noteForm,
DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'),
TimelineEntryItem,
......@@ -92,9 +88,6 @@ export default {
currentUser() {
return this.getUserData;
},
author() {
return this.firstNote.author;
},
autosaveKey() {
return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id);
},
......@@ -104,27 +97,6 @@ export default {
firstNote() {
return this.discussion.notes.slice(0, 1)[0];
},
lastUpdatedBy() {
const { notes } = this.discussion;
if (notes.length > 1) {
return notes[notes.length - 1].author;
}
return null;
},
lastUpdatedAt() {
const { notes } = this.discussion;
if (notes.length > 1) {
return notes[notes.length - 1].created_at;
}
return null;
},
resolvedText() {
return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
},
shouldShowJumpToNextDiscussion() {
return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion');
},
......@@ -150,40 +122,6 @@ export default {
shouldHideDiscussionBody() {
return this.shouldRenderDiffs && !this.isExpanded;
},
actionText() {
const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`;
const linkEnd = '</a>';
let { commit_id: commitId } = this.discussion;
if (commitId) {
commitId = `<span class="commit-sha">${truncateSha(commitId)}</span>`;
}
const {
for_commit: isForCommit,
diff_discussion: isDiffDiscussion,
active: isActive,
} = this.discussion;
let text = s__('MergeRequests|started a thread');
if (isForCommit) {
text = s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}');
} else if (isDiffDiscussion && commitId) {
text = isActive
? s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}')
: s__(
'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}',
);
} else if (isDiffDiscussion) {
text = isActive
? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}')
: s__(
'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}',
);
}
return sprintf(text, { commitId, linkStart, linkEnd }, false);
},
diffLine() {
if (this.line) {
return this.line;
......@@ -208,16 +146,11 @@ export default {
methods: {
...mapActions([
'saveNote',
'toggleDiscussion',
'removePlaceholderNotes',
'toggleResolveNote',
'expandDiscussion',
'removeConvertedDiscussion',
]),
truncateSha,
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.discussion.id });
},
showReplyForm() {
this.isReplying = true;
},
......@@ -311,43 +244,7 @@ export default {
class="discussion js-discussion-container"
data-qa-selector="discussion_content"
>
<div v-if="shouldRenderDiffs" class="discussion-header note-wrapper">
<div v-once class="timeline-icon align-self-start flex-shrink-0">
<user-avatar-link
v-if="author"
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
/>
</div>
<div class="timeline-content w-100">
<note-header
:author="author"
:created-at="firstNote.created_at"
:note-id="firstNote.id"
:include-toggle="true"
:expanded="discussion.expanded"
@toggleHandler="toggleDiscussionHandler"
>
<span v-html="actionText"></span>
</note-header>
<note-edited-text
v-if="discussion.resolved"
:edited-at="discussion.resolved_at"
:edited-by="discussion.resolved_by"
:action-text="resolvedText"
class-name="discussion-headline-light js-discussion-headline"
/>
<note-edited-text
v-else-if="lastUpdatedAt"
:edited-at="lastUpdatedAt"
:edited-by="lastUpdatedBy"
action-text="Last updated"
class-name="discussion-headline-light js-discussion-headline"
/>
</div>
</div>
<diff-discussion-header v-if="shouldRenderDiffs" :discussion="discussion" />
<div v-if="!shouldHideDiscussionBody" class="discussion-body">
<component
:is="wrapperComponent"
......
<script>
import { mapState, mapActions } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
import _ from 'underscore';
import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
......@@ -23,6 +24,7 @@ export default {
'markdownDocsPath',
'markdownPreviewPath',
'releasesPagePath',
'updateReleaseApiDocsPath',
]),
showForm() {
return !this.isFetchingRelease && !this.fetchError;
......@@ -42,6 +44,20 @@ export default {
tagName() {
return this.$store.state.release.tagName;
},
tagNameHintText() {
return sprintf(
__(
'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}',
),
{
linkStart: `<a href="${_.escape(
this.updateReleaseApiDocsPath,
)}" target="_blank" rel="noopener noreferrer">`,
linkEnd: '</a>',
},
false,
);
},
releaseTitle: {
get() {
return this.$store.state.release.name;
......@@ -77,22 +93,22 @@ export default {
<div class="d-flex flex-column">
<p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
<form v-if="showForm" @submit.prevent="updateRelease()">
<div class="row">
<gl-form-group class="col-md-6 col-lg-5 col-xl-4">
<label for="git-ref">{{ __('Tag name') }}</label>
<gl-form-input
id="git-ref"
v-model="tagName"
type="text"
class="form-control"
aria-describedby="tag-name-help"
disabled
/>
<div id="tag-name-help" class="form-text text-muted">
{{ __('Choose an existing tag, or create a new one') }}
<gl-form-group>
<div class="row">
<div class="col-md-6 col-lg-5 col-xl-4">
<label for="git-ref">{{ __('Tag name') }}</label>
<gl-form-input
id="git-ref"
v-model="tagName"
type="text"
class="form-control"
aria-describedby="tag-name-help"
disabled
/>
</div>
</gl-form-group>
</div>
</div>
<div id="tag-name-help" class="form-text text-muted" v-html="tagNameHintText"></div>
</gl-form-group>
<gl-form-group>
<label for="release-title">{{ __('Release title') }}</label>
<gl-form-input
......
......@@ -4,6 +4,7 @@ export default () => ({
releasesPagePath: null,
markdownDocsPath: null,
markdownPreviewPath: null,
updateReleaseApiDocsPath: null,
release: null,
......
......@@ -16,7 +16,6 @@ export default function setupVueRepositoryList() {
const { dataset } = el;
const { projectPath, projectShortPath, ref, fullName } = dataset;
const router = createRouter(projectPath, ref);
const hideOnRootEls = document.querySelectorAll('.js-hide-on-root');
apolloProvider.clients.defaultClient.cache.writeData({
data: {
......@@ -28,20 +27,7 @@ export default function setupVueRepositoryList() {
});
router.afterEach(({ params: { pathMatch } }) => {
const isRoot = pathMatch === undefined || pathMatch === '/';
setTitle(pathMatch, ref, fullName);
if (!isRoot) {
document
.querySelectorAll('.js-keep-hidden-on-navigation')
.forEach(elem => elem.classList.add('hidden'));
}
document
.querySelectorAll('.js-hide-on-navigation')
.forEach(elem => elem.classList.toggle('hidden', !isRoot));
hideOnRootEls.forEach(elem => elem.classList.toggle('hidden', isRoot));
});
const breadcrumbEl = document.getElementById('js-repo-breadcrumb');
......
<script>
import TreeContent from '../components/tree_content.vue';
import TreePage from './tree.vue';
import { updateElementsVisibility } from '../utils/dom';
export default {
components: {
TreeContent,
TreePage,
},
mounted() {
this.updateProjectElements(true);
},
beforeDestroy() {
this.updateProjectElements(false);
},
methods: {
updateProjectElements(isShow) {
updateElementsVisibility('.js-show-on-project-root', isShow);
},
},
};
</script>
<template>
<tree-content />
<tree-page path="/" />
</template>
<script>
import TreeContent from '../components/tree_content.vue';
import { updateElementsVisibility } from '../utils/dom';
export default {
components: {
......@@ -12,6 +13,23 @@ export default {
default: '/',
},
},
computed: {
isRoot() {
return this.path === '/';
},
},
watch: {
isRoot: {
immediate: true,
handler: 'updateElements',
},
},
methods: {
updateElements(isRoot) {
updateElementsVisibility('.js-show-on-root', isRoot);
updateElementsVisibility('.js-hide-on-root', !isRoot);
},
},
};
</script>
......
// eslint-disable-next-line import/prefer-default-export
export const updateElementsVisibility = (selector, isVisible) => {
document.querySelectorAll(selector).forEach(elem => elem.classList.toggle('hidden', !isVisible));
};
const DEFAULT_TITLE = '· GitLab';
// eslint-disable-next-line import/prefer-default-export
export const setTitle = (pathMatch, ref, project) => {
if (!pathMatch) return;
if (!pathMatch) {
document.title = `${project} ${DEFAULT_TITLE}`;
return;
}
const path = pathMatch.replace(/^\//, '');
const isEmpty = path === '';
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`;
document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`;
};
<script>
import _ from 'underscore';
import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { GlLoadingIcon, GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
import ProjectListItem from './project_list_item.vue';
const SEARCH_INPUT_TIMEOUT_MS = 500;
......@@ -10,6 +10,7 @@ export default {
components: {
GlLoadingIcon,
GlSearchBoxByType,
GlInfiniteScroll,
ProjectListItem,
},
props: {
......@@ -41,6 +42,11 @@ export default {
required: false,
default: false,
},
totalResults: {
type: Number,
required: false,
default: 0,
},
},
data() {
return {
......@@ -51,6 +57,9 @@ export default {
projectClicked(project) {
this.$emit('projectClicked', project);
},
bottomReached() {
this.$emit('bottomReached');
},
isSelected(project) {
return Boolean(_.find(this.selectedProjects, { id: project.id }));
},
......@@ -71,18 +80,25 @@ export default {
@input="onInput"
/>
<div class="d-flex flex-column">
<gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" />
<div v-if="!showLoadingIndicator" class="d-flex flex-column">
<project-list-item
v-for="project in projectSearchResults"
:key="project.id"
:selected="isSelected(project)"
:project="project"
:matcher="searchQuery"
class="js-project-list-item"
@click="projectClicked(project)"
/>
</div>
<gl-loading-icon v-if="showLoadingIndicator" :size="1" class="py-2 px-4" />
<gl-infinite-scroll
:max-list-height="402"
:fetched-items="projectSearchResults.length"
:total-items="totalResults"
@bottomReached="bottomReached"
>
<div v-if="!showLoadingIndicator" slot="items" class="d-flex flex-column">
<project-list-item
v-for="project in projectSearchResults"
:key="project.id"
:selected="isSelected(project)"
:project="project"
:matcher="searchQuery"
class="js-project-list-item"
@click="projectClicked(project)"
/>
</div>
</gl-infinite-scroll>
<div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message">
{{ __('Sorry, no projects matched your search') }}
</div>
......
......@@ -297,9 +297,7 @@ module ApplicationSettingsHelper
:snowplow_iglu_registry_url,
:push_event_hooks_limit,
:push_event_activities_limit,
:custom_http_clone_url_root,
:pendo_enabled,
:pendo_url
:custom_http_clone_url_root
]
end
......
......@@ -26,7 +26,8 @@ module ReleasesHelper
tag_name: @release.tag,
markdown_preview_path: preview_markdown_path(@project),
markdown_docs_path: help_page_path('user/markdown'),
releases_page_path: project_releases_path(@project, anchor: @release.tag)
releases_page_path: project_releases_path(@project, anchor: @release.tag),
update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release')
}
end
end
......@@ -4,7 +4,7 @@ module RepositoryLanguagesHelper
def repository_languages_bar(languages)
return if languages.none?
content_tag :div, class: 'progress repository-languages-bar' do
content_tag :div, class: 'progress repository-languages-bar js-show-on-project-root' do
safe_join(languages.map { |lang| language_progress(lang) })
end
end
......
......@@ -6,6 +6,12 @@ class ApplicationSetting < ApplicationRecord
include TokenAuthenticatable
include ChronicDurationAttribute
# Only remove this >= %12.6 and >= 2019-12-01
self.ignored_columns += %i[
pendo_enabled
pendo_url
]
add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
add_authentication_token_field :health_check_access_token
add_authentication_token_field :static_objects_external_storage_auth_token
......@@ -103,11 +109,6 @@ class ApplicationSetting < ApplicationRecord
allow_blank: true,
if: :snowplow_enabled
validates :pendo_url,
presence: true,
public_url: true,
if: :pendo_enabled
validates :max_attachment_size,
presence: true,
numericality: { only_integer: true, greater_than: 0 }
......
......@@ -135,8 +135,6 @@ module ApplicationSettingImplementation
snowplow_app_id: nil,
snowplow_iglu_registry_url: nil,
custom_http_clone_url_root: nil,
pendo_enabled: false,
pendo_url: nil,
productivity_analytics_start_date: Time.now
}
end
......
......@@ -7,5 +7,5 @@
= render_if_exists 'admin/application_settings/slack'
= render 'admin/application_settings/third_party_offers'
= render 'admin/application_settings/snowplow'
= render_if_exists 'admin/application_settings/pendo'
= render 'admin/application_settings/eks' if Feature.enabled?(:create_eks_clusters)
......@@ -90,4 +90,3 @@
= render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id')
= render 'layouts/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id')
= render 'layouts/snowplow'
= render_if_exists 'layouts/pendo'
......@@ -15,7 +15,7 @@
= render 'shared/commit_well', commit: commit, ref: ref, project: project
- if is_project_overview
.project-buttons.append-bottom-default{ class: ("js-keep-hidden-on-navigation" if vue_file_list_enabled?) }
.project-buttons.append-bottom-default{ class: ("js-show-on-project-root" if vue_file_list_enabled?) }
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
- if vue_file_list_enabled?
......
......@@ -3,7 +3,7 @@
- max_project_topic_length = 15
- emails_disabled = @project.emails_disabled?
.project-home-panel{ class: [("empty-project" if empty_repo), ("js-keep-hidden-on-navigation" if vue_file_list_enabled?)] }
.project-home-panel{ class: [("empty-project" if empty_repo), ("js-show-on-project-root" if vue_file_list_enabled?)] }
.row.append-bottom-8
.home-panel-title-row.col-md-12.col-lg-6.d-flex
.avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none
......
- if readme.rich_viewer
%article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-hide-on-navigation" if vue_file_list_enabled?)] }
%article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-show-on-root" if vue_file_list_enabled?)] }
.js-file-title.file-title
= blob_icon readme.mode, readme.name
= link_to project_blob_path(@project, tree_join(@ref, readme.path)) do
......
---
title: Manage and display labels from epic in the GraphQL API
merge_request: 19642
author:
type: added
---
title: Fix scroll to bottom with new job log
merge_request:
author:
type: fixed
---
title: Add Infinite scroll to Add Projects modal in the operations dashboard
merge_request: 17842
author:
type: fixed
---
title: Update help text of "Tag name" field on Edit Release page
merge_request: 19321
author:
type: changed
# frozen_string_literal: true
class CreatePlanLimits < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
create_table :plan_limits, id: false do |t|
t.references :plan, foreign_key: { on_delete: :cascade }, null: false, index: { unique: true }
t.integer :ci_active_pipelines, null: false, default: 0
t.integer :ci_pipeline_size, null: false, default: 0
t.integer :ci_active_jobs, null: false, default: 0
end
end
end
# frozen_string_literal: true
class MoveLimitsFromPlans < ActiveRecord::Migration[5.2]
DOWNTIME = false
def up
execute <<~SQL
INSERT INTO plan_limits (plan_id, ci_active_pipelines, ci_pipeline_size, ci_active_jobs)
SELECT id, COALESCE(active_pipelines_limit, 0), COALESCE(pipeline_size_limit, 0), COALESCE(active_jobs_limit, 0)
FROM plans
SQL
end
def down
execute 'DELETE FROM plan_limits'
end
end
# frozen_string_literal: true
class RemovePendoFromApplicationSettings < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
def up
remove_column :application_settings, :pendo_enabled
remove_column :application_settings, :pendo_url
end
def down
add_column_with_default :application_settings, :pendo_enabled, :boolean, default: false, allow_null: false
add_column :application_settings, :pendo_url, :string, limit: 255
end
end
# frozen_string_literal: true
class RemoveLimitsFromPlans < ActiveRecord::Migration[5.2]
DOWNTIME = false
def up
remove_column :plans, :active_pipelines_limit
remove_column :plans, :pipeline_size_limit
remove_column :plans, :active_jobs_limit
end
def down
add_column :plans, :active_pipelines_limit, :integer
add_column :plans, :pipeline_size_limit, :integer
add_column :plans, :active_jobs_limit, :integer
end
end
......@@ -342,8 +342,6 @@ ActiveRecord::Schema.define(version: 2019_11_12_115317) do
t.integer "push_event_hooks_limit", default: 3, null: false
t.integer "push_event_activities_limit", default: 3, null: false
t.string "custom_http_clone_url_root", limit: 511
t.boolean "pendo_enabled", default: false, null: false
t.string "pendo_url", limit: 255
t.integer "deletion_adjourned_period", default: 7, null: false
t.date "license_trial_ends_on"
t.boolean "eks_integration_enabled", default: false, null: false
......@@ -2802,14 +2800,19 @@ ActiveRecord::Schema.define(version: 2019_11_12_115317) do
t.index ["user_id"], name: "index_personal_access_tokens_on_user_id"
end
create_table "plan_limits", force: :cascade do |t|
t.bigint "plan_id", null: false
t.integer "ci_active_pipelines", default: 0, null: false
t.integer "ci_pipeline_size", default: 0, null: false
t.integer "ci_active_jobs", default: 0, null: false
t.index ["plan_id"], name: "index_plan_limits_on_plan_id", unique: true
end
create_table "plans", id: :serial, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "name"
t.string "title"
t.integer "active_pipelines_limit"
t.integer "pipeline_size_limit"
t.integer "active_jobs_limit", default: 0
t.index ["name"], name: "index_plans_on_name"
end
......@@ -4389,6 +4392,7 @@ ActiveRecord::Schema.define(version: 2019_11_12_115317) do
add_foreign_key "path_locks", "projects", name: "fk_5265c98f24", on_delete: :cascade
add_foreign_key "path_locks", "users"
add_foreign_key "personal_access_tokens", "users"
add_foreign_key "plan_limits", "plans", on_delete: :cascade
add_foreign_key "pool_repositories", "projects", column: "source_project_id", on_delete: :nullify
add_foreign_key "pool_repositories", "shards", on_delete: :restrict
add_foreign_key "project_alerting_settings", "projects", on_delete: :cascade
......
......@@ -216,6 +216,11 @@ type CreateDiffNotePayload {
Autogenerated input type of CreateEpic
"""
input CreateEpicInput {
"""
The IDs of labels to be added to the epic.
"""
addLabelIds: [ID!]
"""
A unique identifier for the client performing the mutation.
"""
......@@ -241,6 +246,11 @@ input CreateEpicInput {
"""
groupPath: ID!
"""
The IDs of labels to be removed from the epic.
"""
removeLabelIds: [ID!]
"""
The start date of the epic
"""
......@@ -1171,6 +1181,31 @@ type Epic implements Noteable {
last: Int
): EpicIssueConnection
"""
Labels assigned to the epic
"""
labels(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): LabelConnection
"""
All notes on this noteable
"""
......@@ -5061,6 +5096,11 @@ type TreeEntryEdge {
Autogenerated input type of UpdateEpic
"""
input UpdateEpicInput {
"""
The IDs of labels to be added to the epic.
"""
addLabelIds: [ID!]
"""
A unique identifier for the client performing the mutation.
"""
......@@ -5091,6 +5131,11 @@ input UpdateEpicInput {
"""
iid: String!
"""
The IDs of labels to be removed from the epic.
"""
removeLabelIds: [ID!]
"""
The start date of the epic
"""
......
......@@ -321,8 +321,6 @@ are listed in the descriptions of the relevant settings.
| `snowplow_enabled` | boolean | no | Enable snowplow tracking. |
| `snowplow_app_id` | string | no | The Snowplow site name / application id. (e.g. `gitlab`) |
| `snowplow_iglu_registry_url` | string | no | The Snowplow base Iglu Schema Registry URL to use for custom context and self describing events'|
| `pendo_url` | string | required by: `pendo_enabled` | The Pendo endpoint url with js snippet. (e.g. `https://cdn.pendo.io/agent/static/your-api-key/pendo.js`) |
| `pendo_enabled` | boolean | no | Enable pendo tracking. |
| `terminal_max_session_time` | integer | no | Maximum time for web terminal websocket connection (in seconds). Set to `0` for unlimited time. |
| `terms` | text | required by: `enforce_terms` | (**Required by:** `enforce_terms`) Markdown content for the ToS. |
| `throttle_authenticated_api_enabled` | boolean | no | (**If enabled, requires:** `throttle_authenticated_api_period_in_seconds` and `throttle_authenticated_api_requests_per_period`) Enable authenticated API request rate limit. Helps reduce request volume (e.g. from crawlers or abusive bots). |
......
......@@ -68,7 +68,3 @@ Once enabled, tracking events can be inspected locally by either:
- Looking at the network panel of the browser's development tools
- Using the [Snowplow Chrome Extension](https://chrome.google.com/webstore/detail/snowplow-inspector/maplkdomeamdlngconidoefjpogkmljm).
## Additional libraries
Session tracking is handled by [Pendo](https://www.pendo.io/), which is a purely client library and is a relatively minor development concern but is worth including in this documentation.
......@@ -19,38 +19,7 @@ You can use it either for personal or business websites, such as
portfolios, documentation, manifestos, and business presentations.
You can also attribute any license to your content.
<table class="borderless-table center fixed-table">
<tr>
<td style="width: 22%"><img src="img/icons/cogs.png" alt="SSGs" class="image-noshadow half-width"></td>
<td style="width: 4%">
<strong>
<i class="fa fa-angle-double-right" aria-hidden="true"></i>
</strong>
</td>
<td style="width: 22%"><img src="img/icons/monitor.png" alt="Websites" class="image-noshadow half-width"></td>
<td style="width: 4%">
<strong>
<i class="fa fa-angle-double-right" aria-hidden="true"></i>
</strong>
</td>
<td style="width: 22%"><img src="img/icons/free.png" alt="Pages is free" class="image-noshadow half-width"></td>
<td style="width: 4%">
<strong>
<i class="fa fa-angle-double-right" aria-hidden="true"></i>
</strong>
</td>
<td style="width: 22%"><img src="img/icons/lock.png" alt="Secure your website" class="image-noshadow half-width"></td>
</tr>
<tr>
<td><em>Use any static website generator or plain HTML</em></td>
<td></td>
<td><em>Create websites for your projects, groups, or user account</em></td>
<td></td>
<td><em>Host on GitLab.com for free, or on your own GitLab instance</em></td>
<td></td>
<td><em>Connect your custom domain(s) and TLS certificates</em></td>
</tr>
</table>
<img src="img/pages_workflow_v12_5.png" alt="Pages websites workflow" class="image-noshadow">
Pages is available for free for all GitLab.com users as well as for self-managed
instances (GitLab Core, Starter, Premium, and Ultimate).
......@@ -95,6 +64,8 @@ To get started with GitLab Pages, you can either:
- [Copy an existing sample](getting_started/fork_sample_project.md).
- [Create a website from scratch or deploy an existing one](getting_started/new_or_existing_website.md).
<img src="img/new_project_for_pages_v12_5.png" alt="New projects for GitLab Pages" class="image-noshadow">
Optional features:
- Use a [custom domain or subdomain](custom_domains_ssl_tls_certification/index.md#set-up-pages-with-a-custom-domain).
......
......@@ -147,10 +147,6 @@ module API
optional :snowplow_cookie_domain, type: String, desc: 'The Snowplow cookie domain'
optional :snowplow_app_id, type: String, desc: 'The Snowplow site name / application id'
end
optional :pendo_enabled, type: Grape::API::Boolean, desc: 'Enable Pendo tracking'
given pendo_enabled: ->(val) { val } do
requires :pendo_url, type: String, desc: 'The Pendo url endpoint'
end
ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
optional :"#{type}_key_restriction",
......
......@@ -3016,6 +3016,9 @@ msgstr ""
msgid "Changes won't take place until the index is %{link_start}recreated%{link_end}."
msgstr ""
msgid "Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}"
msgstr ""
msgid "Changing group path can have unintended side effects."
msgstr ""
......@@ -3142,9 +3145,6 @@ msgstr ""
msgid "Choose a type..."
msgstr ""
msgid "Choose an existing tag, or create a new one"
msgstr ""
msgid "Choose any color."
msgstr ""
......@@ -6229,9 +6229,6 @@ msgstr ""
msgid "Enable or disable version check and usage ping."
msgstr ""
msgid "Enable pendo tracking"
msgstr ""
msgid "Enable protected paths rate limit"
msgstr ""
......@@ -10660,10 +10657,10 @@ msgstr ""
msgid "MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}"
msgstr ""
msgid "MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}"
msgid "MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitDisplay}%{linkEnd}"
msgstr ""
msgid "MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}"
msgid "MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}"
msgstr ""
msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}"
......@@ -11986,12 +11983,6 @@ msgstr ""
msgid "Pending"
msgstr ""
msgid "Pendo"
msgstr ""
msgid "Pendo endpoint"
msgstr ""
msgid "People without permission will never get a notification and won't be able to comment."
msgstr ""
......
import { mount, createLocalVue } from '@vue/test-utils';
import createStore from '~/notes/stores';
import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue';
import { discussionMock } from '../../../javascripts/notes/mock_data';
import mockDiffFile from '../../diffs/mock_data/diff_discussions';
const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json';
describe('diff_discussion_header component', () => {
let store;
let wrapper;
preloadFixtures(discussionWithTwoUnresolvedNotes);
beforeEach(() => {
window.mrTabs = {};
store = createStore();
const localVue = createLocalVue();
wrapper = mount(diffDiscussionHeader, {
store,
propsData: { discussion: discussionMock },
localVue,
sync: false,
});
});
afterEach(() => {
wrapper.destroy();
});
it('should render user avatar', () => {
const discussion = { ...discussionMock };
discussion.diff_file = mockDiffFile;
discussion.diff_discussion = true;
wrapper.setProps({ discussion });
expect(wrapper.find('.user-avatar-link').exists()).toBe(true);
});
describe('action text', () => {
const commitId = 'razupaltuff';
const truncatedCommitId = commitId.substr(0, 8);
let commitElement;
beforeEach(done => {
store.state.diffs = {
projectPath: 'something',
};
wrapper.setProps({
discussion: {
...discussionMock,
for_commit: true,
commit_id: commitId,
diff_discussion: true,
diff_file: {
...mockDiffFile,
},
},
});
wrapper.vm
.$nextTick()
.then(() => {
commitElement = wrapper.find('.commit-sha');
})
.then(done)
.catch(done.fail);
});
describe('for diff threads without a commit id', () => {
it('should show started a thread on the diff text', done => {
Object.assign(wrapper.vm.discussion, {
for_commit: false,
commit_id: null,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.text()).toContain('started a thread on the diff');
done();
});
});
it('should show thread on older version text', done => {
Object.assign(wrapper.vm.discussion, {
for_commit: false,
commit_id: null,
active: false,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.text()).toContain('started a thread on an old version of the diff');
done();
});
});
});
describe('for commit threads', () => {
it('should display a monospace started a thread on commit', () => {
expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`);
expect(commitElement.exists()).toBe(true);
expect(commitElement.text()).toContain(truncatedCommitId);
});
});
describe('for diff thread with a commit id', () => {
it('should display started thread on commit header', done => {
wrapper.vm.discussion.for_commit = false;
wrapper.vm.$nextTick(() => {
expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`);
expect(commitElement).not.toBe(null);
done();
});
});
it('should display outdated change on commit header', done => {
wrapper.vm.discussion.for_commit = false;
wrapper.vm.discussion.active = false;
wrapper.vm.$nextTick(() => {
expect(wrapper.text()).toContain(
`started a thread on an outdated change in commit ${truncatedCommitId}`,
);
expect(commitElement).not.toBe(null);
done();
});
});
});
});
});
......@@ -8,15 +8,17 @@ describe('Release detail component', () => {
let wrapper;
let releaseClone;
let actions;
let state;
beforeEach(() => {
gon.api_version = 'v4';
releaseClone = JSON.parse(JSON.stringify(convertObjectPropsToCamelCase(release)));
const state = {
state = {
release: releaseClone,
markdownDocsPath: 'path/to/markdown/docs',
updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
};
actions = {
......@@ -46,6 +48,21 @@ describe('Release detail component', () => {
expect(wrapper.find('#git-ref').element.value).toBe(releaseClone.tagName);
});
it('renders the correct help text under the "Tag name" field', () => {
const helperText = wrapper.find('#tag-name-help');
const helperTextLink = helperText.find('a');
const helperTextLinkAttrs = helperTextLink.attributes();
expect(helperText.text()).toBe(
'Changing a Release tag is only supported via Releases API. More information',
);
expect(helperTextLink.text()).toBe('More information');
expect(helperTextLinkAttrs.href).toBe(state.updateReleaseApiDocsPath);
expect(helperTextLinkAttrs.rel).toContain('noopener');
expect(helperTextLinkAttrs.rel).toContain('noreferrer');
expect(helperTextLinkAttrs.target).toBe('_blank');
});
it('renders the correct release title in the "Release title" field', () => {
expect(wrapper.find('#release-title').element.value).toBe(releaseClone.name);
});
......
import { shallowMount } from '@vue/test-utils';
import IndexPage from '~/repository/pages/index.vue';
import TreePage from '~/repository/pages/tree.vue';
import { updateElementsVisibility } from '~/repository/utils/dom';
jest.mock('~/repository/utils/dom');
describe('Repository index page component', () => {
let wrapper;
function factory() {
wrapper = shallowMount(IndexPage);
}
afterEach(() => {
wrapper.destroy();
updateElementsVisibility.mockClear();
});
it('calls updateElementsVisibility on mounted', () => {
factory();
expect(updateElementsVisibility).toHaveBeenCalledWith('.js-show-on-project-root', true);
});
it('calls updateElementsVisibility after destroy', () => {
factory();
wrapper.destroy();
expect(updateElementsVisibility.mock.calls.pop()).toEqual(['.js-show-on-project-root', false]);
});
it('renders TreePage', () => {
factory();
const child = wrapper.find(TreePage);
expect(child.exists()).toBe(true);
expect(child.props()).toEqual({ path: '/' });
});
});
import { shallowMount } from '@vue/test-utils';
import TreePage from '~/repository/pages/tree.vue';
import { updateElementsVisibility } from '~/repository/utils/dom';
jest.mock('~/repository/utils/dom');
describe('Repository tree page component', () => {
let wrapper;
function factory(path) {
wrapper = shallowMount(TreePage, { propsData: { path } });
}
afterEach(() => {
wrapper.destroy();
updateElementsVisibility.mockClear();
});
describe('when root path', () => {
beforeEach(() => {
factory('/');
});
it('shows root elements', () => {
expect(updateElementsVisibility.mock.calls).toEqual([
['.js-show-on-root', true],
['.js-hide-on-root', false],
]);
});
describe('when changed', () => {
beforeEach(() => {
updateElementsVisibility.mockClear();
wrapper.setProps({ path: '/test' });
});
it('hides root elements', () => {
expect(updateElementsVisibility.mock.calls).toEqual([
['.js-show-on-root', false],
['.js-hide-on-root', true],
]);
});
});
});
describe('when non-root path', () => {
beforeEach(() => {
factory('/test');
});
it('hides root elements', () => {
expect(updateElementsVisibility.mock.calls).toEqual([
['.js-show-on-root', false],
['.js-hide-on-root', true],
]);
});
});
});
import { setHTMLFixture } from '../../helpers/fixtures';
import { updateElementsVisibility } from '~/repository/utils/dom';
describe('updateElementsVisibility', () => {
it('adds hidden class', () => {
setHTMLFixture('<div class="js-test"></div>');
updateElementsVisibility('.js-test', false);
expect(document.querySelector('.js-test').classList).toContain('hidden');
});
it('removes hidden class', () => {
setHTMLFixture('<div class="hidden js-test"></div>');
updateElementsVisibility('.js-test', true);
expect(document.querySelector('.js-test').classList).not.toContain('hidden');
});
});
......@@ -8,8 +8,8 @@ describe('setTitle', () => {
${'app/assets'} | ${'app/assets'}
${'app/assets/javascripts'} | ${'app/assets/javascripts'}
`('sets document title as $title for $path', ({ path, title }) => {
setTitle(path, 'master', 'GitLab');
setTitle(path, 'master', 'GitLab Org / GitLab');
expect(document.title).toEqual(`${title} · master · GitLab`);
expect(document.title).toEqual(`${title} · master · GitLab Org / GitLab · GitLab`);
});
});
......@@ -39,7 +39,6 @@ describe ApplicationSettingsHelper do
context 'with tracking parameters' do
it { expect(visible_attributes).to include(*%i(snowplow_collector_hostname snowplow_cookie_domain snowplow_enabled snowplow_app_id)) }
it { expect(visible_attributes).to include(*%i(pendo_enabled pendo_url)) }
end
describe '.integration_expanded?' do
......
......@@ -17,9 +17,11 @@ describe ReleasesHelper do
context 'url helpers' do
let(:project) { build(:project, namespace: create(:group)) }
let(:release) { create(:release, project: project) }
before do
helper.instance_variable_set(:@project, project)
helper.instance_variable_set(:@release, release)
end
describe '#data_for_releases_page' do
......@@ -28,5 +30,17 @@ describe ReleasesHelper do
expect(helper.data_for_releases_page.keys).to eq(keys)
end
end
describe '#data_for_edit_release_page' do
it 'has the needed data to display the "edit release" page' do
keys = %i(project_id
tag_name
markdown_preview_path
markdown_docs_path
releases_page_path
update_release_api_docs_path)
expect(helper.data_for_edit_release_page.keys).to eq(keys)
end
end
end
end
......@@ -247,7 +247,7 @@ describe('Frequent Items App Component', () => {
.then(() => {
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
mockSearchedProjects.length,
mockSearchedProjects.data.length,
);
})
.then(done)
......
......@@ -68,7 +68,7 @@ export const mockFrequentGroups = [
},
];
export const mockSearchedGroups = [mockRawGroup];
export const mockSearchedGroups = { data: [mockRawGroup] };
export const mockProcessedSearchedGroups = [mockGroup];
export const mockProject = {
......@@ -135,7 +135,7 @@ export const mockFrequentProjects = [
},
];
export const mockSearchedProjects = [mockRawProject];
export const mockSearchedProjects = { data: [mockRawProject] };
export const mockProcessedSearchedProjects = [mockProject];
export const unsortedFrequentItems = [
......
......@@ -169,7 +169,7 @@ describe('Frequent Items Dropdown Store Actions', () => {
});
it('should dispatch `receiveSearchedItemsSuccess`', done => {
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects);
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {});
testAction(
actions.fetchSearchedItems,
......@@ -178,7 +178,10 @@ describe('Frequent Items Dropdown Store Actions', () => {
[],
[
{ type: 'requestSearchedItems' },
{ type: 'receiveSearchedItemsSuccess', payload: mockSearchedProjects },
{
type: 'receiveSearchedItemsSuccess',
payload: { data: mockSearchedProjects, headers: {} },
},
],
done,
);
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { mount, createLocalVue } from '@vue/test-utils';
import createStore from '~/notes/stores';
import noteableDiscussion from '~/notes/components/noteable_discussion.vue';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
......@@ -23,7 +23,7 @@ describe('noteable_discussion component', () => {
store.dispatch('setNotesData', notesDataMock);
const localVue = createLocalVue();
wrapper = shallowMount(noteableDiscussion, {
wrapper = mount(noteableDiscussion, {
store,
propsData: { discussion: discussionMock },
localVue,
......@@ -35,16 +35,6 @@ describe('noteable_discussion component', () => {
wrapper.destroy();
});
it('should render user avatar', () => {
const discussion = { ...discussionMock };
discussion.diff_file = mockDiffFile;
discussion.diff_discussion = true;
wrapper.setProps({ discussion, renderDiffFile: true });
expect(wrapper.find('.user-avatar-link').exists()).toBe(true);
});
it('should not render thread header for non diff threads', () => {
expect(wrapper.find('.discussion-header').exists()).toBe(false);
});
......@@ -134,105 +124,6 @@ describe('noteable_discussion component', () => {
});
});
describe('action text', () => {
const commitId = 'razupaltuff';
const truncatedCommitId = commitId.substr(0, 8);
let commitElement;
beforeEach(done => {
store.state.diffs = {
projectPath: 'something',
};
wrapper.setProps({
discussion: {
...discussionMock,
for_commit: true,
commit_id: commitId,
diff_discussion: true,
diff_file: {
...mockDiffFile,
},
},
renderDiffFile: true,
});
wrapper.vm
.$nextTick()
.then(() => {
commitElement = wrapper.find('.commit-sha');
})
.then(done)
.catch(done.fail);
});
describe('for commit threads', () => {
it('should display a monospace started a thread on commit', () => {
expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`);
expect(commitElement.exists()).toBe(true);
expect(commitElement.text()).toContain(truncatedCommitId);
});
});
describe('for diff thread with a commit id', () => {
it('should display started thread on commit header', done => {
wrapper.vm.discussion.for_commit = false;
wrapper.vm.$nextTick(() => {
expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`);
expect(commitElement).not.toBe(null);
done();
});
});
it('should display outdated change on commit header', done => {
wrapper.vm.discussion.for_commit = false;
wrapper.vm.discussion.active = false;
wrapper.vm.$nextTick(() => {
expect(wrapper.text()).toContain(
`started a thread on an outdated change in commit ${truncatedCommitId}`,
);
expect(commitElement).not.toBe(null);
done();
});
});
});
describe('for diff threads without a commit id', () => {
it('should show started a thread on the diff text', done => {
Object.assign(wrapper.vm.discussion, {
for_commit: false,
commit_id: null,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.text()).toContain('started a thread on the diff');
done();
});
});
it('should show thread on older version text', done => {
Object.assign(wrapper.vm.discussion, {
for_commit: false,
commit_id: null,
active: false,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.text()).toContain('started a thread on an old version of the diff');
done();
});
});
});
});
describe('for resolved thread', () => {
beforeEach(() => {
const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0];
......@@ -262,6 +153,7 @@ describe('noteable_discussion component', () => {
}));
wrapper.setProps({ discussion });
wrapper.vm
.$nextTick()
.then(done)
......
......@@ -3,7 +3,7 @@ import _ from 'underscore';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
import { GlSearchBoxByType } from '@gitlab/ui';
import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import { trimText } from 'spec/helpers/text_helper';
......@@ -91,6 +91,13 @@ describe('ProjectSelector component', () => {
expect(searchInput.attributes('placeholder')).toBe('Search your projects');
});
it(`triggers a "bottomReached" event when user has scrolled to the bottom of the list`, () => {
spyOn(vm, '$emit');
wrapper.find(GlInfiniteScroll).vm.$emit('bottomReached');
expect(vm.$emit).toHaveBeenCalledWith('bottomReached');
});
it(`triggers a "projectClicked" event when a project is clicked`, () => {
spyOn(vm, '$emit');
wrapper.find(ProjectListItem).vm.$emit('click', _.first(searchResults));
......
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'migrate', '20191030152934_move_limits_from_plans.rb')
describe MoveLimitsFromPlans, :migration do
let(:plans) { table(:plans) }
let(:plan_limits) { table(:plan_limits) }
let!(:early_adopter_plan) { plans.create(name: 'early_adopter', title: 'Early adopter', active_pipelines_limit: 10, pipeline_size_limit: 11, active_jobs_limit: 12) }
let!(:gold_plan) { plans.create(name: 'gold', title: 'Gold', active_pipelines_limit: 20, pipeline_size_limit: 21, active_jobs_limit: 22) }
let!(:silver_plan) { plans.create(name: 'silver', title: 'Silver', active_pipelines_limit: 30, pipeline_size_limit: 31, active_jobs_limit: 32) }
let!(:bronze_plan) { plans.create(name: 'bronze', title: 'Bronze', active_pipelines_limit: 40, pipeline_size_limit: 41, active_jobs_limit: 42) }
let!(:free_plan) { plans.create(name: 'free', title: 'Free', active_pipelines_limit: 50, pipeline_size_limit: 51, active_jobs_limit: 52) }
let!(:other_plan) { plans.create(name: 'other', title: 'Other', active_pipelines_limit: nil, pipeline_size_limit: nil, active_jobs_limit: 0) }
describe 'migrate' do
it 'populates plan_limits from all the records in plans' do
expect { migrate! }.to change { plan_limits.count }.by 6
end
it 'copies plan limits and plan.id into to plan_limits table' do
migrate!
new_data = plan_limits.pluck(:plan_id, :ci_active_pipelines, :ci_pipeline_size, :ci_active_jobs)
expected_data = [
[early_adopter_plan.id, 10, 11, 12],
[gold_plan.id, 20, 21, 22],
[silver_plan.id, 30, 31, 32],
[bronze_plan.id, 40, 41, 42],
[free_plan.id, 50, 51, 52],
[other_plan.id, 0, 0, 0]
]
expect(new_data).to contain_exactly(*expected_data)
end
end
end
......@@ -82,22 +82,6 @@ describe ApplicationSetting do
it { is_expected.to allow_value(nil).for(:snowplow_iglu_registry_url) }
end
context 'when pendo is enabled' do
before do
setting.pendo_enabled = true
end
it { is_expected.not_to allow_value(nil).for(:pendo_url) }
it { is_expected.to allow_value(http).for(:pendo_url) }
it { is_expected.to allow_value(https).for(:pendo_url) }
it { is_expected.not_to allow_value(ftp).for(:pendo_url) }
it { is_expected.not_to allow_value('http://127.0.0.1').for(:pendo_url) }
end
context 'when pendo is not enabled' do
it { is_expected.to allow_value(nil).for(:pendo_url) }
end
context "when user accepted let's encrypt terms of service" do
before do
setting.update(lets_encrypt_terms_of_service_accepted: true)
......
......@@ -223,54 +223,6 @@ describe API::Settings, 'Settings' do
end
end
context "pendo tracking settings" do
let(:settings) do
{
pendo_url: "https://pendo.example.com",
pendo_enabled: true
}
end
let(:attribute_names) { settings.keys.map(&:to_s) }
it "includes the attributes in the API" do
get api("/application/settings", admin)
expect(response).to have_gitlab_http_status(200)
attribute_names.each do |attribute|
expect(json_response.keys).to include(attribute)
end
end
it "allows updating the settings" do
put api("/application/settings", admin), params: settings
expect(response).to have_gitlab_http_status(200)
settings.each do |attribute, value|
expect(ApplicationSetting.current.public_send(attribute)).to eq(value)
end
end
context "missing pendo_url value when pendo_enabled is true" do
it "returns a blank parameter error message" do
put api("/application/settings", admin), params: { pendo_enabled: true }
expect(response).to have_gitlab_http_status(400)
expect(json_response["error"]).to eq("pendo_url is missing")
end
it "handles validation errors" do
put api("/application/settings", admin), params: settings.merge({
pendo_url: nil
})
expect(response).to have_gitlab_http_status(400)
message = json_response["message"]
expect(message["pendo_url"]).to include("can't be blank")
end
end
end
context 'EKS integration settings' do
let(:attribute_names) { settings.keys.map(&:to_s) }
let(:sensitive_attributes) { %w(eks_secret_access_key) }
......
......@@ -606,6 +606,24 @@ describe Issues::UpdateService, :mailer do
end
end
context 'when same id is passed as add_label_ids and remove_label_ids' do
let(:params) { { add_label_ids: [label.id], remove_label_ids: [label.id] } }
context 'for a label assigned to an issue' do
it 'removes the label' do
issue.update(labels: [label])
expect(result.label_ids).to be_empty
end
end
context 'for a label not assigned to an issue' do
it 'does not add the label' do
expect(result.label_ids).to be_empty
end
end
end
context 'when duplicate label titles are given' do
let(:params) do
{ labels: [label3.title, label3.title] }
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册