Commit 6d31b8f0 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 69944ffb
...@@ -113,10 +113,9 @@ const Api = { ...@@ -113,10 +113,9 @@ const Api = {
.get(url, { .get(url, {
params: Object.assign(defaults, options), params: Object.assign(defaults, options),
}) })
.then(({ data }) => { .then(({ data, headers }) => {
callback(data); callback(data);
return { data, headers };
return data;
}); });
}, },
......
...@@ -47,7 +47,8 @@ export default { ...@@ -47,7 +47,8 @@ export default {
hasSearchQuery: true, hasSearchQuery: true,
}); });
}, },
[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) { [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) {
const rawItems = results.data;
Object.assign(state, { Object.assign(state, {
items: rawItems.map(rawItem => ({ items: rawItems.map(rawItem => ({
id: rawItem.id, id: rawItem.id,
......
...@@ -11,11 +11,35 @@ export default { ...@@ -11,11 +11,35 @@ export default {
computed: { computed: {
...mapState(['traceEndpoint', 'trace', 'isTraceComplete']), ...mapState(['traceEndpoint', 'trace', 'isTraceComplete']),
}, },
updated() {
this.$nextTick(() => {
this.handleScrollDown();
});
},
mounted() {
this.$nextTick(() => {
this.handleScrollDown();
});
},
methods: { methods: {
...mapActions(['toggleCollapsibleLine']), ...mapActions(['toggleCollapsibleLine', 'scrollBottom']),
handleOnClickCollapsibleLine(section) { handleOnClickCollapsibleLine(section) {
this.toggleCollapsibleLine(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>
......
<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> <script>
import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui'; import { GlTooltipDirective } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __ } from '~/locale';
import { s__, __, sprintf } from '~/locale';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash'; import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; 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 noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue'; import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue'; import diffWithNote from './diff_with_note.vue';
import noteable from '../mixins/noteable'; import noteable from '../mixins/noteable';
...@@ -27,9 +24,8 @@ export default { ...@@ -27,9 +24,8 @@ export default {
components: { components: {
icon, icon,
userAvatarLink, userAvatarLink,
noteHeader, diffDiscussionHeader,
noteSignedOutWidget, noteSignedOutWidget,
noteEditedText,
noteForm, noteForm,
DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'), DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'),
TimelineEntryItem, TimelineEntryItem,
...@@ -92,9 +88,6 @@ export default { ...@@ -92,9 +88,6 @@ export default {
currentUser() { currentUser() {
return this.getUserData; return this.getUserData;
}, },
author() {
return this.firstNote.author;
},
autosaveKey() { autosaveKey() {
return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id);
}, },
...@@ -104,27 +97,6 @@ export default { ...@@ -104,27 +97,6 @@ export default {
firstNote() { firstNote() {
return this.discussion.notes.slice(0, 1)[0]; 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() { shouldShowJumpToNextDiscussion() {
return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion'); return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion');
}, },
...@@ -150,40 +122,6 @@ export default { ...@@ -150,40 +122,6 @@ export default {
shouldHideDiscussionBody() { shouldHideDiscussionBody() {
return this.shouldRenderDiffs && !this.isExpanded; 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() { diffLine() {
if (this.line) { if (this.line) {
return this.line; return this.line;
...@@ -208,16 +146,11 @@ export default { ...@@ -208,16 +146,11 @@ export default {
methods: { methods: {
...mapActions([ ...mapActions([
'saveNote', 'saveNote',
'toggleDiscussion',
'removePlaceholderNotes', 'removePlaceholderNotes',
'toggleResolveNote', 'toggleResolveNote',
'expandDiscussion', 'expandDiscussion',
'removeConvertedDiscussion', 'removeConvertedDiscussion',
]), ]),
truncateSha,
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.discussion.id });
},
showReplyForm() { showReplyForm() {
this.isReplying = true; this.isReplying = true;
}, },
...@@ -311,43 +244,7 @@ export default { ...@@ -311,43 +244,7 @@ export default {
class="discussion js-discussion-container" class="discussion js-discussion-container"
data-qa-selector="discussion_content" data-qa-selector="discussion_content"
> >
<div v-if="shouldRenderDiffs" class="discussion-header note-wrapper"> <diff-discussion-header v-if="shouldRenderDiffs" :discussion="discussion" />
<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>
<div v-if="!shouldHideDiscussionBody" class="discussion-body"> <div v-if="!shouldHideDiscussionBody" class="discussion-body">
<component <component
:is="wrapperComponent" :is="wrapperComponent"
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
import _ from 'underscore';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
...@@ -23,6 +24,7 @@ export default { ...@@ -23,6 +24,7 @@ export default {
'markdownDocsPath', 'markdownDocsPath',
'markdownPreviewPath', 'markdownPreviewPath',
'releasesPagePath', 'releasesPagePath',
'updateReleaseApiDocsPath',
]), ]),
showForm() { showForm() {
return !this.isFetchingRelease && !this.fetchError; return !this.isFetchingRelease && !this.fetchError;
...@@ -42,6 +44,20 @@ export default { ...@@ -42,6 +44,20 @@ export default {
tagName() { tagName() {
return this.$store.state.release.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: { releaseTitle: {
get() { get() {
return this.$store.state.release.name; return this.$store.state.release.name;
...@@ -77,22 +93,22 @@ export default { ...@@ -77,22 +93,22 @@ export default {
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<p class="pt-3 js-subtitle-text" v-html="subtitleText"></p> <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
<form v-if="showForm" @submit.prevent="updateRelease()"> <form v-if="showForm" @submit.prevent="updateRelease()">
<div class="row"> <gl-form-group>
<gl-form-group class="col-md-6 col-lg-5 col-xl-4"> <div class="row">
<label for="git-ref">{{ __('Tag name') }}</label> <div class="col-md-6 col-lg-5 col-xl-4">
<gl-form-input <label for="git-ref">{{ __('Tag name') }}</label>
id="git-ref" <gl-form-input
v-model="tagName" id="git-ref"
type="text" v-model="tagName"
class="form-control" type="text"
aria-describedby="tag-name-help" class="form-control"
disabled 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') }}
</div> </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> <gl-form-group>
<label for="release-title">{{ __('Release title') }}</label> <label for="release-title">{{ __('Release title') }}</label>
<gl-form-input <gl-form-input
......
...@@ -4,6 +4,7 @@ export default () => ({ ...@@ -4,6 +4,7 @@ export default () => ({
releasesPagePath: null, releasesPagePath: null,
markdownDocsPath: null, markdownDocsPath: null,
markdownPreviewPath: null, markdownPreviewPath: null,
updateReleaseApiDocsPath: null,
release: null, release: null,
......
...@@ -16,7 +16,6 @@ export default function setupVueRepositoryList() { ...@@ -16,7 +16,6 @@ export default function setupVueRepositoryList() {
const { dataset } = el; const { dataset } = el;
const { projectPath, projectShortPath, ref, fullName } = dataset; const { projectPath, projectShortPath, ref, fullName } = dataset;
const router = createRouter(projectPath, ref); const router = createRouter(projectPath, ref);
const hideOnRootEls = document.querySelectorAll('.js-hide-on-root');
apolloProvider.clients.defaultClient.cache.writeData({ apolloProvider.clients.defaultClient.cache.writeData({
data: { data: {
...@@ -28,20 +27,7 @@ export default function setupVueRepositoryList() { ...@@ -28,20 +27,7 @@ export default function setupVueRepositoryList() {
}); });
router.afterEach(({ params: { pathMatch } }) => { router.afterEach(({ params: { pathMatch } }) => {
const isRoot = pathMatch === undefined || pathMatch === '/';
setTitle(pathMatch, ref, fullName); 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'); const breadcrumbEl = document.getElementById('js-repo-breadcrumb');
......
<script> <script>
import TreeContent from '../components/tree_content.vue'; import TreePage from './tree.vue';
import { updateElementsVisibility } from '../utils/dom';
export default { export default {
components: { components: {
TreeContent, TreePage,
},
mounted() {
this.updateProjectElements(true);
},
beforeDestroy() {
this.updateProjectElements(false);
},
methods: {
updateProjectElements(isShow) {
updateElementsVisibility('.js-show-on-project-root', isShow);
},
}, },
}; };
</script> </script>
<template> <template>
<tree-content /> <tree-page path="/" />
</template> </template>
<script> <script>
import TreeContent from '../components/tree_content.vue'; import TreeContent from '../components/tree_content.vue';
import { updateElementsVisibility } from '../utils/dom';
export default { export default {
components: { components: {
...@@ -12,6 +13,23 @@ export default { ...@@ -12,6 +13,23 @@ export default {
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> </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 // eslint-disable-next-line import/prefer-default-export
export const setTitle = (pathMatch, ref, project) => { export const setTitle = (pathMatch, ref, project) => {
if (!pathMatch) return; if (!pathMatch) {
document.title = `${project} ${DEFAULT_TITLE}`;
return;
}
const path = pathMatch.replace(/^\//, ''); const path = pathMatch.replace(/^\//, '');
const isEmpty = path === ''; const isEmpty = path === '';
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ /* 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> <script>
import _ from 'underscore'; import _ from 'underscore';
import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import { GlLoadingIcon, GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
import ProjectListItem from './project_list_item.vue'; import ProjectListItem from './project_list_item.vue';
const SEARCH_INPUT_TIMEOUT_MS = 500; const SEARCH_INPUT_TIMEOUT_MS = 500;
...@@ -10,6 +10,7 @@ export default { ...@@ -10,6 +10,7 @@ export default {
components: { components: {
GlLoadingIcon, GlLoadingIcon,
GlSearchBoxByType, GlSearchBoxByType,
GlInfiniteScroll,
ProjectListItem, ProjectListItem,
}, },
props: { props: {
...@@ -41,6 +42,11 @@ export default { ...@@ -41,6 +42,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
totalResults: {
type: Number,
required: false,
default: 0,
},
}, },
data() { data() {
return { return {
...@@ -51,6 +57,9 @@ export default { ...@@ -51,6 +57,9 @@ export default {
projectClicked(project) { projectClicked(project) {
this.$emit('projectClicked', project); this.$emit('projectClicked', project);
}, },
bottomReached() {
this.$emit('bottomReached');
},
isSelected(project) { isSelected(project) {
return Boolean(_.find(this.selectedProjects, { id: project.id })); return Boolean(_.find(this.selectedProjects, { id: project.id }));
}, },
...@@ -71,18 +80,25 @@ export default { ...@@ -71,18 +80,25 @@ export default {
@input="onInput" @input="onInput"
/> />
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" /> <gl-loading-icon v-if="showLoadingIndicator" :size="1" class="py-2 px-4" />
<div v-if="!showLoadingIndicator" class="d-flex flex-column"> <gl-infinite-scroll
<project-list-item :max-list-height="402"
v-for="project in projectSearchResults" :fetched-items="projectSearchResults.length"
:key="project.id" :total-items="totalResults"
:selected="isSelected(project)" @bottomReached="bottomReached"
:project="project" >
:matcher="searchQuery" <div v-if="!showLoadingIndicator" slot="items" class="d-flex flex-column">
class="js-project-list-item" <project-list-item
@click="projectClicked(project)" v-for="project in projectSearchResults"
/> :key="project.id"
</div> :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"> <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message">
{{ __('Sorry, no projects matched your search') }} {{ __('Sorry, no projects matched your search') }}
</div> </div>
......
...@@ -297,9 +297,7 @@ module ApplicationSettingsHelper ...@@ -297,9 +297,7 @@ module ApplicationSettingsHelper
:snowplow_iglu_registry_url, :snowplow_iglu_registry_url,
:push_event_hooks_limit, :push_event_hooks_limit,
:push_event_activities_limit, :push_event_activities_limit,
:custom_http_clone_url_root, :custom_http_clone_url_root
:pendo_enabled,
:pendo_url
] ]
end end
......
...@@ -26,7 +26,8 @@ module ReleasesHelper ...@@ -26,7 +26,8 @@ module ReleasesHelper
tag_name: @release.tag, tag_name: @release.tag,
markdown_preview_path: preview_markdown_path(@project), markdown_preview_path: preview_markdown_path(@project),
markdown_docs_path: help_page_path('user/markdown'), 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
end end
...@@ -4,7 +4,7 @@ module RepositoryLanguagesHelper ...@@ -4,7 +4,7 @@ module RepositoryLanguagesHelper
def repository_languages_bar(languages) def repository_languages_bar(languages)
return if languages.none? 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) }) safe_join(languages.map { |lang| language_progress(lang) })
end end
end end
......
...@@ -6,6 +6,12 @@ class ApplicationSetting < ApplicationRecord ...@@ -6,6 +6,12 @@ class ApplicationSetting < ApplicationRecord
include TokenAuthenticatable include TokenAuthenticatable
include ChronicDurationAttribute 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 :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 :health_check_access_token
add_authentication_token_field :static_objects_external_storage_auth_token add_authentication_token_field :static_objects_external_storage_auth_token
...@@ -103,11 +109,6 @@ class ApplicationSetting < ApplicationRecord ...@@ -103,11 +109,6 @@ class ApplicationSetting < ApplicationRecord
allow_blank: true, allow_blank: true,
if: :snowplow_enabled if: :snowplow_enabled
validates :pendo_url,
presence: true,
public_url: true,
if: :pendo_enabled
validates :max_attachment_size, validates :max_attachment_size,
presence: true, presence: true,
numericality: { only_integer: true, greater_than: 0 } numericality: { only_integer: true, greater_than: 0 }
......
...@@ -135,8 +135,6 @@ module ApplicationSettingImplementation ...@@ -135,8 +135,6 @@ module ApplicationSettingImplementation
snowplow_app_id: nil, snowplow_app_id: nil,
snowplow_iglu_registry_url: nil, snowplow_iglu_registry_url: nil,
custom_http_clone_url_root: nil, custom_http_clone_url_root: nil,
pendo_enabled: false,
pendo_url: nil,
productivity_analytics_start_date: Time.now productivity_analytics_start_date: Time.now
} }
end end
......
...@@ -7,5 +7,5 @@ ...@@ -7,5 +7,5 @@
= render_if_exists 'admin/application_settings/slack' = render_if_exists 'admin/application_settings/slack'
= render 'admin/application_settings/third_party_offers' = render 'admin/application_settings/third_party_offers'
= render 'admin/application_settings/snowplow' = render 'admin/application_settings/snowplow'
= render_if_exists 'admin/application_settings/pendo'
= render 'admin/application_settings/eks' if Feature.enabled?(:create_eks_clusters) = render 'admin/application_settings/eks' if Feature.enabled?(:create_eks_clusters)
...@@ -90,4 +90,3 @@ ...@@ -90,4 +90,3 @@
= render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id') = 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/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id')
= render 'layouts/snowplow' = render 'layouts/snowplow'
= render_if_exists 'layouts/pendo'
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
= render 'shared/commit_well', commit: commit, ref: ref, project: project = render 'shared/commit_well', commit: commit, ref: ref, project: project
- if is_project_overview - 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) = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
- if vue_file_list_enabled? - if vue_file_list_enabled?
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
- max_project_topic_length = 15 - max_project_topic_length = 15
- emails_disabled = @project.emails_disabled? - 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 .row.append-bottom-8
.home-panel-title-row.col-md-12.col-lg-6.d-flex .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 .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none
......
- if readme.rich_viewer - 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 .js-file-title.file-title
= blob_icon readme.mode, readme.name = blob_icon readme.mode, readme.name
= link_to project_blob_path(@project, tree_join(@ref, readme.path)) do = 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 ...@@ -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_hooks_limit", default: 3, null: false
t.integer "push_event_activities_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.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.integer "deletion_adjourned_period", default: 7, null: false
t.date "license_trial_ends_on" t.date "license_trial_ends_on"
t.boolean "eks_integration_enabled", default: false, null: false t.boolean "eks_integration_enabled", default: false, null: false
...@@ -2802,14 +2800,19 @@ ActiveRecord::Schema.define(version: 2019_11_12_115317) do ...@@ -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" t.index ["user_id"], name: "index_personal_access_tokens_on_user_id"
end 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| create_table "plans", id: :serial, force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "name" t.string "name"
t.string "title" 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" t.index ["name"], name: "index_plans_on_name"
end end
...@@ -4389,6 +4392,7 @@ ActiveRecord::Schema.define(version: 2019_11_12_115317) do ...@@ -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", "projects", name: "fk_5265c98f24", on_delete: :cascade
add_foreign_key "path_locks", "users" add_foreign_key "path_locks", "users"
add_foreign_key "personal_access_tokens", "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", "projects", column: "source_project_id", on_delete: :nullify
add_foreign_key "pool_repositories", "shards", on_delete: :restrict add_foreign_key "pool_repositories", "shards", on_delete: :restrict
add_foreign_key "project_alerting_settings", "projects", on_delete: :cascade add_foreign_key "project_alerting_settings", "projects", on_delete: :cascade
......
...@@ -216,6 +216,11 @@ type CreateDiffNotePayload { ...@@ -216,6 +216,11 @@ type CreateDiffNotePayload {
Autogenerated input type of CreateEpic Autogenerated input type of CreateEpic
""" """
input CreateEpicInput { input CreateEpicInput {
"""
The IDs of labels to be added to the epic.
"""
addLabelIds: [ID!]
""" """
A unique identifier for the client performing the mutation. A unique identifier for the client performing the mutation.
""" """
...@@ -241,6 +246,11 @@ input CreateEpicInput { ...@@ -241,6 +246,11 @@ input CreateEpicInput {
""" """
groupPath: ID! groupPath: ID!
"""
The IDs of labels to be removed from the epic.
"""
removeLabelIds: [ID!]
""" """
The start date of the epic The start date of the epic
""" """
...@@ -1171,6 +1181,31 @@ type Epic implements Noteable { ...@@ -1171,6 +1181,31 @@ type Epic implements Noteable {
last: Int last: Int
): EpicIssueConnection ): 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 All notes on this noteable
""" """
...@@ -5061,6 +5096,11 @@ type TreeEntryEdge { ...@@ -5061,6 +5096,11 @@ type TreeEntryEdge {
Autogenerated input type of UpdateEpic Autogenerated input type of UpdateEpic
""" """
input UpdateEpicInput { input UpdateEpicInput {
"""
The IDs of labels to be added to the epic.
"""
addLabelIds: [ID!]
""" """
A unique identifier for the client performing the mutation. A unique identifier for the client performing the mutation.
""" """
...@@ -5091,6 +5131,11 @@ input UpdateEpicInput { ...@@ -5091,6 +5131,11 @@ input UpdateEpicInput {
""" """
iid: String! iid: String!
"""
The IDs of labels to be removed from the epic.
"""
removeLabelIds: [ID!]
""" """
The start date of the epic The start date of the epic
""" """
......
...@@ -321,8 +321,6 @@ are listed in the descriptions of the relevant settings. ...@@ -321,8 +321,6 @@ are listed in the descriptions of the relevant settings.
| `snowplow_enabled` | boolean | no | Enable snowplow tracking. | | `snowplow_enabled` | boolean | no | Enable snowplow tracking. |
| `snowplow_app_id` | string | no | The Snowplow site name / application id. (e.g. `gitlab`) | | `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'| | `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. | | `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. | | `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). | | `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: ...@@ -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 - 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). - 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 ...@@ -19,38 +19,7 @@ You can use it either for personal or business websites, such as
portfolios, documentation, manifestos, and business presentations. portfolios, documentation, manifestos, and business presentations.
You can also attribute any license to your content. You can also attribute any license to your content.
<table class="borderless-table center fixed-table"> <img src="img/pages_workflow_v12_5.png" alt="Pages websites workflow" class="image-noshadow">
<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>
Pages is available for free for all GitLab.com users as well as for self-managed Pages is available for free for all GitLab.com users as well as for self-managed
instances (GitLab Core, Starter, Premium, and Ultimate). instances (GitLab Core, Starter, Premium, and Ultimate).
...@@ -95,6 +64,8 @@ To get started with GitLab Pages, you can either: ...@@ -95,6 +64,8 @@ To get started with GitLab Pages, you can either:
- [Copy an existing sample](getting_started/fork_sample_project.md). - [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). - [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: Optional features:
- Use a [custom domain or subdomain](custom_domains_ssl_tls_certification/index.md#set-up-pages-with-a-custom-domain). - 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 ...@@ -147,10 +147,6 @@ module API
optional :snowplow_cookie_domain, type: String, desc: 'The Snowplow cookie domain' optional :snowplow_cookie_domain, type: String, desc: 'The Snowplow cookie domain'
optional :snowplow_app_id, type: String, desc: 'The Snowplow site name / application id' optional :snowplow_app_id, type: String, desc: 'The Snowplow site name / application id'
end 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| ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
optional :"#{type}_key_restriction", optional :"#{type}_key_restriction",
......
...@@ -3016,6 +3016,9 @@ msgstr "" ...@@ -3016,6 +3016,9 @@ msgstr ""
msgid "Changes won't take place until the index is %{link_start}recreated%{link_end}." msgid "Changes won't take place until the index is %{link_start}recreated%{link_end}."
msgstr "" 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." msgid "Changing group path can have unintended side effects."
msgstr "" msgstr ""
...@@ -3142,9 +3145,6 @@ msgstr "" ...@@ -3142,9 +3145,6 @@ msgstr ""
msgid "Choose a type..." msgid "Choose a type..."
msgstr "" msgstr ""
msgid "Choose an existing tag, or create a new one"
msgstr ""
msgid "Choose any color." msgid "Choose any color."
msgstr "" msgstr ""
...@@ -6229,9 +6229,6 @@ msgstr "" ...@@ -6229,9 +6229,6 @@ msgstr ""
msgid "Enable or disable version check and usage ping." msgid "Enable or disable version check and usage ping."
msgstr "" msgstr ""
msgid "Enable pendo tracking"
msgstr ""
msgid "Enable protected paths rate limit" msgid "Enable protected paths rate limit"
msgstr "" msgstr ""
...@@ -10660,10 +10657,10 @@ msgstr "" ...@@ -10660,10 +10657,10 @@ msgstr ""
msgid "MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}" msgid "MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}"
msgstr "" 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 "" msgstr ""
msgid "MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}" msgid "MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}"
msgstr "" msgstr ""
msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}" msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}"
...@@ -11986,12 +11983,6 @@ msgstr "" ...@@ -11986,12 +11983,6 @@ msgstr ""
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
msgid "Pendo"
msgstr ""
msgid "Pendo endpoint"
msgstr ""
msgid "People without permission will never get a notification and won't be able to comment." msgid "People without permission will never get a notification and won't be able to comment."
msgstr "" 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', () => { ...@@ -8,15 +8,17 @@ describe('Release detail component', () => {
let wrapper; let wrapper;
let releaseClone; let releaseClone;
let actions; let actions;
let state;
beforeEach(() => { beforeEach(() => {
gon.api_version = 'v4'; gon.api_version = 'v4';
releaseClone = JSON.parse(JSON.stringify(convertObjectPropsToCamelCase(release))); releaseClone = JSON.parse(JSON.stringify(convertObjectPropsToCamelCase(release)));
const state = { state = {
release: releaseClone, release: releaseClone,
markdownDocsPath: 'path/to/markdown/docs', markdownDocsPath: 'path/to/markdown/docs',
updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
}; };
actions = { actions = {
...@@ -46,6 +48,21 @@ describe('Release detail component', () => { ...@@ -46,6 +48,21 @@ describe('Release detail component', () => {
expect(wrapper.find('#git-ref').element.value).toBe(releaseClone.tagName); 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', () => { it('renders the correct release title in the "Release title" field', () => {
expect(wrapper.find('#release-title').element.value).toBe(releaseClone.name); 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', () => { ...@@ -8,8 +8,8 @@ describe('setTitle', () => {
${'app/assets'} | ${'app/assets'} ${'app/assets'} | ${'app/assets'}
${'app/assets/javascripts'} | ${'app/assets/javascripts'} ${'app/assets/javascripts'} | ${'app/assets/javascripts'}
`('sets document title as $title for $path', ({ path, title }) => { `('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 ...@@ -39,7 +39,6 @@ describe ApplicationSettingsHelper do
context 'with tracking parameters' 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(snowplow_collector_hostname snowplow_cookie_domain snowplow_enabled snowplow_app_id)) }
it { expect(visible_attributes).to include(*%i(pendo_enabled pendo_url)) }
end end
describe '.integration_expanded?' do describe '.integration_expanded?' do
......
...@@ -17,9 +17,11 @@ describe ReleasesHelper do ...@@ -17,9 +17,11 @@ describe ReleasesHelper do
context 'url helpers' do context 'url helpers' do
let(:project) { build(:project, namespace: create(:group)) } let(:project) { build(:project, namespace: create(:group)) }
let(:release) { create(:release, project: project) }
before do before do
helper.instance_variable_set(:@project, project) helper.instance_variable_set(:@project, project)
helper.instance_variable_set(:@release, release)
end end
describe '#data_for_releases_page' do describe '#data_for_releases_page' do
...@@ -28,5 +30,17 @@ describe ReleasesHelper do ...@@ -28,5 +30,17 @@ describe ReleasesHelper do
expect(helper.data_for_releases_page.keys).to eq(keys) expect(helper.data_for_releases_page.keys).to eq(keys)
end end
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
end end
...@@ -247,7 +247,7 @@ describe('Frequent Items App Component', () => { ...@@ -247,7 +247,7 @@ describe('Frequent Items App Component', () => {
.then(() => { .then(() => {
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe( expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
mockSearchedProjects.length, mockSearchedProjects.data.length,
); );
}) })
.then(done) .then(done)
......
...@@ -68,7 +68,7 @@ export const mockFrequentGroups = [ ...@@ -68,7 +68,7 @@ export const mockFrequentGroups = [
}, },
]; ];
export const mockSearchedGroups = [mockRawGroup]; export const mockSearchedGroups = { data: [mockRawGroup] };
export const mockProcessedSearchedGroups = [mockGroup]; export const mockProcessedSearchedGroups = [mockGroup];
export const mockProject = { export const mockProject = {
...@@ -135,7 +135,7 @@ export const mockFrequentProjects = [ ...@@ -135,7 +135,7 @@ export const mockFrequentProjects = [
}, },
]; ];
export const mockSearchedProjects = [mockRawProject]; export const mockSearchedProjects = { data: [mockRawProject] };
export const mockProcessedSearchedProjects = [mockProject]; export const mockProcessedSearchedProjects = [mockProject];
export const unsortedFrequentItems = [ export const unsortedFrequentItems = [
......
...@@ -169,7 +169,7 @@ describe('Frequent Items Dropdown Store Actions', () => { ...@@ -169,7 +169,7 @@ describe('Frequent Items Dropdown Store Actions', () => {
}); });
it('should dispatch `receiveSearchedItemsSuccess`', done => { it('should dispatch `receiveSearchedItemsSuccess`', done => {
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects); mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {});
testAction( testAction(
actions.fetchSearchedItems, actions.fetchSearchedItems,
...@@ -178,7 +178,10 @@ describe('Frequent Items Dropdown Store Actions', () => { ...@@ -178,7 +178,10 @@ describe('Frequent Items Dropdown Store Actions', () => {
[], [],
[ [
{ type: 'requestSearchedItems' }, { type: 'requestSearchedItems' },
{ type: 'receiveSearchedItemsSuccess', payload: mockSearchedProjects }, {
type: 'receiveSearchedItemsSuccess',
payload: { data: mockSearchedProjects, headers: {} },
},
], ],
done, done,
); );
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import createStore from '~/notes/stores'; import createStore from '~/notes/stores';
import noteableDiscussion from '~/notes/components/noteable_discussion.vue'; import noteableDiscussion from '~/notes/components/noteable_discussion.vue';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
...@@ -23,7 +23,7 @@ describe('noteable_discussion component', () => { ...@@ -23,7 +23,7 @@ describe('noteable_discussion component', () => {
store.dispatch('setNotesData', notesDataMock); store.dispatch('setNotesData', notesDataMock);
const localVue = createLocalVue(); const localVue = createLocalVue();
wrapper = shallowMount(noteableDiscussion, { wrapper = mount(noteableDiscussion, {
store, store,
propsData: { discussion: discussionMock }, propsData: { discussion: discussionMock },
localVue, localVue,
...@@ -35,16 +35,6 @@ describe('noteable_discussion component', () => { ...@@ -35,16 +35,6 @@ describe('noteable_discussion component', () => {
wrapper.destroy(); 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', () => { it('should not render thread header for non diff threads', () => {
expect(wrapper.find('.discussion-header').exists()).toBe(false); expect(wrapper.find('.discussion-header').exists()).toBe(false);
}); });
...@@ -134,105 +124,6 @@ describe('noteable_discussion component', () => { ...@@ -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', () => { describe('for resolved thread', () => {
beforeEach(() => { beforeEach(() => {
const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0]; const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0];
...@@ -262,6 +153,7 @@ describe('noteable_discussion component', () => { ...@@ -262,6 +153,7 @@ describe('noteable_discussion component', () => {
})); }));
wrapper.setProps({ discussion }); wrapper.setProps({ discussion });
wrapper.vm wrapper.vm
.$nextTick() .$nextTick()
.then(done) .then(done)
......
...@@ -3,7 +3,7 @@ import _ from 'underscore'; ...@@ -3,7 +3,7 @@ import _ from 'underscore';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.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 { mount, createLocalVue } from '@vue/test-utils';
import { trimText } from 'spec/helpers/text_helper'; import { trimText } from 'spec/helpers/text_helper';
...@@ -91,6 +91,13 @@ describe('ProjectSelector component', () => { ...@@ -91,6 +91,13 @@ describe('ProjectSelector component', () => {
expect(searchInput.attributes('placeholder')).toBe('Search your projects'); 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`, () => { it(`triggers a "projectClicked" event when a project is clicked`, () => {
spyOn(vm, '$emit'); spyOn(vm, '$emit');
wrapper.find(ProjectListItem).vm.$emit('click', _.first(searchResults)); 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 ...@@ -82,22 +82,6 @@ describe ApplicationSetting do
it { is_expected.to allow_value(nil).for(:snowplow_iglu_registry_url) } it { is_expected.to allow_value(nil).for(:snowplow_iglu_registry_url) }
end 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 context "when user accepted let's encrypt terms of service" do
before do before do
setting.update(lets_encrypt_terms_of_service_accepted: true) setting.update(lets_encrypt_terms_of_service_accepted: true)
......
...@@ -223,54 +223,6 @@ describe API::Settings, 'Settings' do ...@@ -223,54 +223,6 @@ describe API::Settings, 'Settings' do
end end
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 context 'EKS integration settings' do
let(:attribute_names) { settings.keys.map(&:to_s) } let(:attribute_names) { settings.keys.map(&:to_s) }
let(:sensitive_attributes) { %w(eks_secret_access_key) } let(:sensitive_attributes) { %w(eks_secret_access_key) }
......
...@@ -606,6 +606,24 @@ describe Issues::UpdateService, :mailer do ...@@ -606,6 +606,24 @@ describe Issues::UpdateService, :mailer do
end end
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 context 'when duplicate label titles are given' do
let(:params) do let(:params) do
{ labels: [label3.title, label3.title] } { labels: [label3.title, label3.title] }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment