Commit f3d45708 authored by Robert Speicher's avatar Robert Speicher

Merge branch 'master' of gitlab.com:gitlab-org/gitlab

parents 23d70567 80a9bea9
...@@ -21,6 +21,11 @@ export default { ...@@ -21,6 +21,11 @@ export default {
default: '', default: '',
required: false, required: false,
}, },
isRawContent: {
type: Boolean,
default: false,
required: false,
},
loading: { loading: {
type: Boolean, type: Boolean,
default: true, default: true,
...@@ -65,6 +70,8 @@ export default { ...@@ -65,6 +70,8 @@ export default {
v-else v-else
ref="contentViewer" ref="contentViewer"
:content="content" :content="content"
:is-raw-content="isRawContent"
:file-name="blob.name"
:type="activeViewer.fileType" :type="activeViewer.fileType"
data-qa-selector="file_content" data-qa-selector="file_content"
/> />
......
...@@ -126,7 +126,7 @@ export default { ...@@ -126,7 +126,7 @@ export default {
const diffLines = this.diffFile[INLINE_DIFF_LINES_KEY]; const diffLines = this.diffFile[INLINE_DIFF_LINES_KEY];
let isAdding = false; let isAdding = false;
for (let i = 0, diffLinesLength = diffLines.length - 1; i < diffLinesLength; i += 1) { for (let i = 0, diffLinesLength = diffLines.length - 1; i <= diffLinesLength; i += 1) {
const line = diffLines[i]; const line = diffLines[i];
if (start.line_code === line.line_code) { if (start.line_code === line.line_code) {
......
<script> <script>
import { GlButton } from '@gitlab/ui'; import { GlButton, GlLink } from '@gitlab/ui';
import ExperimentTracking from '~/experimentation/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default { export default {
components: { GlButton }, components: { GlButton, GlLink },
props: { props: {
displayText: { displayText: {
type: String, type: String,
...@@ -37,6 +37,42 @@ export default { ...@@ -37,6 +37,42 @@ export default {
required: false, required: false,
default: undefined, default: undefined,
}, },
triggerElement: {
type: String,
required: false,
default: 'button',
},
event: {
type: String,
required: false,
default: '',
},
label: {
type: String,
required: false,
default: '',
},
},
computed: {
isButton() {
return this.triggerElement === 'button';
},
componentAttributes() {
const baseAttributes = {
class: this.classes,
'data-qa-selector': 'invite_members_button',
};
if (this.event && this.label) {
return {
...baseAttributes,
'data-track-event': this.event,
'data-track-label': this.label,
};
}
return baseAttributes;
},
}, },
mounted() { mounted() {
this.trackExperimentOnShow(); this.trackExperimentOnShow();
...@@ -57,12 +93,15 @@ export default { ...@@ -57,12 +93,15 @@ export default {
<template> <template>
<gl-button <gl-button
:class="classes" v-if="isButton"
:icon="icon" v-bind="componentAttributes"
:variant="variant" :variant="variant"
data-qa-selector="invite_members_button" :icon="icon"
@click="openModal" @click="openModal"
> >
{{ displayText }} {{ displayText }}
</gl-button> </gl-button>
<gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal">
{{ displayText }}
</gl-link>
</template> </template>
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import getTableHeaders from '../utils'; import getTableHeaders from '../utils';
import PackageTypeToken from './tokens/package_type_token.vue'; import PackageTypeToken from './tokens/package_type_token.vue';
...@@ -16,7 +17,7 @@ export default { ...@@ -16,7 +17,7 @@ export default {
operators: [{ value: '=', description: __('is'), default: 'true' }], operators: [{ value: '=', description: __('is'), default: 'true' }],
}, },
], ],
components: { RegistrySearch }, components: { RegistrySearch, UrlSync },
computed: { computed: {
...mapState({ ...mapState({
isGroupPage: (state) => state.config.isGroupPage, isGroupPage: (state) => state.config.isGroupPage,
...@@ -38,13 +39,18 @@ export default { ...@@ -38,13 +39,18 @@ export default {
</script> </script>
<template> <template>
<registry-search <url-sync>
:filter="filter" <template #default="{ updateQuery }">
:sorting="sorting" <registry-search
:tokens="$options.tokens" :filter="filter"
:sortable-fields="sortableFields" :sorting="sorting"
@sorting:changed="updateSorting" :tokens="$options.tokens"
@filter:changed="setFilter" :sortable-fields="sortableFields"
@filter:submit="$emit('update')" @sorting:changed="updateSorting"
/> @filter:changed="setFilter"
@filter:submit="$emit('update')"
@query:changed="updateQuery"
/>
</template>
</url-sync>
</template> </template>
...@@ -6,6 +6,7 @@ import { historyReplaceState } from '~/lib/utils/common_utils'; ...@@ -6,6 +6,7 @@ import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
import PackageSearch from './package_search.vue'; import PackageSearch from './package_search.vue';
import PackageTitle from './package_title.vue'; import PackageTitle from './package_title.vue';
...@@ -42,11 +43,21 @@ export default { ...@@ -42,11 +43,21 @@ export default {
}, },
}, },
mounted() { mounted() {
const queryParams = getQueryParams(window.document.location.search);
const { sorting, filters } = extractFilterAndSorting(queryParams);
this.setSorting(sorting);
this.setFilter(filters);
this.requestPackagesList(); this.requestPackagesList();
this.checkDeleteAlert(); this.checkDeleteAlert();
}, },
methods: { methods: {
...mapActions(['requestPackagesList', 'requestDeletePackage', 'setSelectedType']), ...mapActions([
'requestPackagesList',
'requestDeletePackage',
'setSelectedType',
'setSorting',
'setFilter',
]),
onPageChanged(page) { onPageChanged(page) {
return this.requestPackagesList({ page }); return this.requestPackagesList({ page });
}, },
......
...@@ -7,3 +7,23 @@ export const keyValueToFilterToken = (type, data) => ({ type, value: { data } }) ...@@ -7,3 +7,23 @@ export const keyValueToFilterToken = (type, data) => ({ type, value: { data } })
export const searchArrayToFilterTokens = (search) => export const searchArrayToFilterTokens = (search) =>
search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s)); search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s));
export const extractFilterAndSorting = (queryObject) => {
const { type, search, sort, orderBy } = queryObject;
const filters = [];
const sorting = {};
if (type) {
filters.push(keyValueToFilterToken('type', type));
}
if (search) {
filters.push(...searchArrayToFilterTokens(search));
}
if (sort) {
sorting.sort = sort;
}
if (orderBy) {
sorting.orderBy = orderBy;
}
return { filters, sorting };
};
...@@ -4,6 +4,7 @@ import initIssuableSidebar from '~/init_issuable_sidebar'; ...@@ -4,6 +4,7 @@ import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { IssuableType } from '~/issuable_show/constants'; import { IssuableType } from '~/issuable_show/constants';
import Issue from '~/issue'; import Issue from '~/issue';
import '~/notes/index'; import '~/notes/index';
...@@ -36,6 +37,7 @@ export default function initShowIssue() { ...@@ -36,6 +37,7 @@ export default function initShowIssue() {
initSentryErrorStackTraceApp(); initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp(); initRelatedMergeRequestsApp();
initInviteMembersModal(); initInviteMembersModal();
initInviteMembersTrigger();
import(/* webpackChunkName: 'design_management' */ '~/design_management') import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then((module) => module.default()) .then((module) => module.default())
......
...@@ -6,6 +6,7 @@ import initIssuableSidebar from '~/init_issuable_sidebar'; ...@@ -6,6 +6,7 @@ import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { handleLocationHash } from '~/lib/utils/common_utils'; import { handleLocationHash } from '~/lib/utils/common_utils';
import StatusBox from '~/merge_request/components/status_box.vue'; import StatusBox from '~/merge_request/components/status_box.vue';
import initSourcegraph from '~/sourcegraph'; import initSourcegraph from '~/sourcegraph';
...@@ -22,6 +23,7 @@ export default function initMergeRequestShow() { ...@@ -22,6 +23,7 @@ export default function initMergeRequestShow() {
initInviteMemberModal(); initInviteMemberModal();
initInviteMemberTrigger(); initInviteMemberTrigger();
initInviteMembersModal(); initInviteMembersModal();
initInviteMembersTrigger();
const el = document.querySelector('.js-mr-status-box'); const el = document.querySelector('.js-mr-status-box');
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
......
...@@ -12,6 +12,7 @@ import { get } from 'lodash'; ...@@ -12,6 +12,7 @@ import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import DeleteImage from '../components/delete_image.vue'; import DeleteImage from '../components/delete_image.vue';
...@@ -82,6 +83,9 @@ export default { ...@@ -82,6 +83,9 @@ export default {
searchConfig: SORT_FIELDS, searchConfig: SORT_FIELDS,
apollo: { apollo: {
baseImages: { baseImages: {
skip() {
return !this.fetchBaseQuery;
},
query: getContainerRepositoriesQuery, query: getContainerRepositoriesQuery,
variables() { variables() {
return this.queryVariables; return this.queryVariables;
...@@ -125,15 +129,19 @@ export default { ...@@ -125,15 +129,19 @@ export default {
sorting: { orderBy: 'UPDATED', sort: 'desc' }, sorting: { orderBy: 'UPDATED', sort: 'desc' },
name: null, name: null,
mutationLoading: false, mutationLoading: false,
fetchBaseQuery: false,
fetchAdditionalDetails: false, fetchAdditionalDetails: false,
}; };
}, },
computed: { computed: {
images() { images() {
return this.baseImages.map((image, index) => ({ if (this.baseImages) {
...image, return this.baseImages.map((image, index) => ({
...get(this.additionalDetails, index, {}), ...image,
})); ...get(this.additionalDetails, index, {}),
}));
}
return [];
}, },
graphqlResource() { graphqlResource() {
return this.config.isGroupPage ? 'group' : 'project'; return this.config.isGroupPage ? 'group' : 'project';
...@@ -172,8 +180,15 @@ export default { ...@@ -172,8 +180,15 @@ export default {
}, },
}, },
mounted() { mounted() {
const { sorting, filters } = extractFilterAndSorting(this.$route.query);
this.filter = [...filters];
this.name = filters[0]?.value.data;
this.sorting = { ...this.sorting, ...sorting };
// If the two graphql calls - which are not batched - resolve togheter we will have a race // If the two graphql calls - which are not batched - resolve togheter we will have a race
// condition when apollo sets the cache, with this we give the 'base' call an headstart // condition when apollo sets the cache, with this we give the 'base' call an headstart
this.fetchBaseQuery = true;
setTimeout(() => { setTimeout(() => {
this.fetchAdditionalDetails = true; this.fetchAdditionalDetails = true;
}, 200); }, 200);
...@@ -245,6 +260,9 @@ export default { ...@@ -245,6 +260,9 @@ export default {
const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM); const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM);
this.name = search?.value?.data; this.name = search?.value?.data;
}, },
updateUrlQueryString(query) {
this.$router.push({ query });
},
}, },
}; };
</script> </script>
...@@ -304,6 +322,7 @@ export default { ...@@ -304,6 +322,7 @@ export default {
@sorting:changed="updateSorting" @sorting:changed="updateSorting"
@filter:changed="filter = $event" @filter:changed="filter = $event"
@filter:submit="doFilter" @filter:submit="doFilter"
@query:changed="updateUrlQueryString"
/> />
<div v-if="isLoading" class="gl-mt-5"> <div v-if="isLoading" class="gl-mt-5">
......
...@@ -114,7 +114,7 @@ export default { ...@@ -114,7 +114,7 @@ export default {
<gl-sprintf <gl-sprintf
:message=" :message="
__( __(
'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.', 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0.0%{codeEnd}, %{codeStart}v2.1.0-pre%{codeEnd}.',
) )
" "
> >
......
...@@ -36,6 +36,12 @@ export default { ...@@ -36,6 +36,12 @@ export default {
blobHash: uniqueId(), blobHash: uniqueId(),
}; };
}, },
props: {
path: {
type: String,
required: true,
},
},
data() { data() {
return { return {
projectPath: '', projectPath: '',
...@@ -85,6 +91,7 @@ export default { ...@@ -85,6 +91,7 @@ export default {
<blob-content <blob-content
:blob="blobInfo" :blob="blobInfo"
:content="blobInfo.rawBlob" :content="blobInfo.rawBlob"
:is-raw-content="true"
:active-viewer="viewer" :active-viewer="viewer"
:loading="false" :loading="false"
/> />
......
...@@ -2,16 +2,21 @@ ...@@ -2,16 +2,21 @@
// This file is in progress and behind a feature flag, please see the following issue for more: // This file is in progress and behind a feature flag, please see the following issue for more:
// https://gitlab.com/gitlab-org/gitlab/-/issues/323200 // https://gitlab.com/gitlab-org/gitlab/-/issues/323200
// TODO (follow-up MR): import BlobContentViewer from '../components/blob_content_viewer.vue'; import BlobContentViewer from '../components/blob_content_viewer.vue';
export default { export default {
components: { components: {
// TODO (follow-up MR): BlobContentViewer, BlobContentViewer,
},
props: {
path: {
type: String,
required: true,
},
}, },
}; };
</script> </script>
<template> <template>
<div></div> <blob-content-viewer :path="path" />
<!-- TODO (follow-up MR): <blob-content-viewer/> -->
</template> </template>
...@@ -11,6 +11,16 @@ export default { ...@@ -11,6 +11,16 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isRawContent: {
type: Boolean,
default: false,
required: false,
},
fileName: {
type: String,
required: false,
default: '',
},
}, },
mounted() { mounted() {
eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT); eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT);
......
<script> <script>
/* eslint-disable vue/no-v-html */ /* eslint-disable vue/no-v-html */
import { GlIcon } from '@gitlab/ui'; import { GlIcon } from '@gitlab/ui';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
import { HIGHLIGHT_CLASS_NAME } from './constants'; import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins'; import ViewerMixin from './mixins';
export default { export default {
components: { components: {
GlIcon, GlIcon,
EditorLite,
}, },
mixins: [ViewerMixin], mixins: [ViewerMixin],
inject: ['blobHash'], inject: ['blobHash'],
...@@ -45,27 +47,36 @@ export default { ...@@ -45,27 +47,36 @@ export default {
}; };
</script> </script>
<template> <template>
<div <div>
class="file-content code js-syntax-highlight" <editor-lite
data-qa-selector="file_content" v-if="isRawContent"
:class="$options.userColorScheme" :value="content"
> :file-name="fileName"
<div class="line-numbers"> :editor-options="{ readOnly: true }"
<a />
v-for="line in lineNumbers" <div
:id="`L${line}`" v-else
:key="line" class="file-content code js-syntax-highlight"
class="diff-line-num js-line-number" data-qa-selector="file_content"
:href="`#LC${line}`" :class="$options.userColorScheme"
:data-line-number="line" >
@click="scrollToLine(`#LC${line}`)" <div class="line-numbers">
> <a
<gl-icon :size="12" name="link" /> v-for="line in lineNumbers"
{{ line }} :id="`L${line}`"
</a> :key="line"
</div> class="diff-line-num js-line-number"
<div class="blob-content"> :href="`#LC${line}`"
<pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre> :data-line-number="line"
@click="scrollToLine(`#LC${line}`)"
>
<gl-icon :size="12" name="link" />
{{ line }}
</a>
</div>
<div class="blob-content">
<pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
</div>
</div> </div>
</div> </div>
</template> </template>
...@@ -168,6 +168,9 @@ export default { ...@@ -168,6 +168,9 @@ export default {
false, false,
); );
}, },
suggestionsStartIndex() {
return Math.max(this.lines.length - 1, 0);
},
}, },
watch: { watch: {
isSubmitting(isSubmitting) { isSubmitting(isSubmitting) {
...@@ -260,7 +263,7 @@ export default { ...@@ -260,7 +263,7 @@ export default {
:line-content="lineContent" :line-content="lineContent"
:can-suggest="canSuggest" :can-suggest="canSuggest"
:show-suggest-popover="showSuggestPopover" :show-suggest-popover="showSuggestPopover"
:suggestion-start-index="lines.length - 1" :suggestion-start-index="suggestionsStartIndex"
@preview-markdown="showPreviewTab" @preview-markdown="showPreviewTab"
@write-markdown="showWriteTab" @write-markdown="showWriteTab"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
......
...@@ -91,15 +91,29 @@ class InvitesController < ApplicationController ...@@ -91,15 +91,29 @@ class InvitesController < ApplicationController
def authenticate_user! def authenticate_user!
return if current_user return if current_user
notice = ["To accept this invitation, sign in"] store_location_for :user, request.fullpath
notice << "or create an account" if Gitlab::CurrentSettings.allow_signup?
notice = notice.join(' ') + "."
redirect_params = member ? { invite_email: member.invite_email } : {} if user_sign_up?
redirect_to new_user_registration_path(invite_email: member.invite_email), notice: _("To accept this invitation, create an account or sign in.")
else
redirect_to new_user_session_path(sign_in_redirect_params), notice: sign_in_notice
end
end
store_location_for :user, request.fullpath def sign_in_redirect_params
member ? { invite_email: member.invite_email } : {}
end
def user_sign_up?
Gitlab::CurrentSettings.allow_signup? && member && !User.find_by_any_email(member.invite_email)
end
redirect_to new_user_session_path(redirect_params), notice: notice def sign_in_notice
if Gitlab::CurrentSettings.allow_signup?
_("To accept this invitation, sign in or create an account.")
else
_("To accept this invitation, sign in.")
end
end end
def invite_details def invite_details
......
...@@ -29,11 +29,9 @@ module Mutations ...@@ -29,11 +29,9 @@ module Mutations
end end
def ready?(**args) def ready?(**args)
if Gitlab::Database.read_only? raise_resource_not_available_error! ERROR_MESSAGE if Gitlab::Database.read_only?
raise_resource_not_available_error! ERROR_MESSAGE
else true
true
end
end end
def load_application_object(argument, lookup_as_type, id, context) def load_application_object(argument, lookup_as_type, id, context)
......
...@@ -7,6 +7,10 @@ module Resolvers ...@@ -7,6 +7,10 @@ module Resolvers
include ResolvesProject include ResolvesProject
type Types::Ci::Config::ConfigType, null: true type Types::Ci::Config::ConfigType, null: true
description <<~MD
Linted and processed contents of a CI config.
Should not be requested more than once per request.
MD
authorize :read_pipeline authorize :read_pipeline
...@@ -55,7 +59,7 @@ module Resolvers ...@@ -55,7 +59,7 @@ module Resolvers
name: job[:name], name: job[:name],
stage: job[:stage], stage: job[:stage],
group_name: CommitStatus.new(name: job[:name]).group_name, group_name: CommitStatus.new(name: job[:name]).group_name,
needs: job.dig(:needs) || [], needs: job[:needs] || [],
allow_failure: job[:allow_failure], allow_failure: job[:allow_failure],
before_script: job[:before_script], before_script: job[:before_script],
script: job[:script], script: job[:script],
......
...@@ -3,24 +3,30 @@ ...@@ -3,24 +3,30 @@
module Resolvers module Resolvers
module Ci module Ci
class RunnerSetupResolver < BaseResolver class RunnerSetupResolver < BaseResolver
ACCESS_DENIED = 'User is not authorized to register a runner for the specified resource!'
type Types::Ci::RunnerSetupType, null: true type Types::Ci::RunnerSetupType, null: true
description 'Runner setup instructions.' description 'Runner setup instructions.'
argument :platform, GraphQL::STRING_TYPE, argument :platform,
required: true, type: GraphQL::STRING_TYPE,
description: 'Platform to generate the instructions for.' required: true,
description: 'Platform to generate the instructions for.'
argument :architecture, GraphQL::STRING_TYPE, argument :architecture,
required: true, type: GraphQL::STRING_TYPE,
description: 'Architecture to generate the instructions for.' required: true,
description: 'Architecture to generate the instructions for.'
argument :project_id, ::Types::GlobalIDType[::Project], argument :project_id,
required: false, type: ::Types::GlobalIDType[::Project],
description: 'Project to register the runner for.' required: false,
description: 'Project to register the runner for.'
argument :group_id, ::Types::GlobalIDType[::Group], argument :group_id,
required: false, type: ::Types::GlobalIDType[::Group],
description: 'Group to register the runner for.' required: false,
description: 'Group to register the runner for.'
def resolve(platform:, architecture:, **args) def resolve(platform:, architecture:, **args)
instructions = Gitlab::Ci::RunnerInstructions.new( instructions = Gitlab::Ci::RunnerInstructions.new(
...@@ -35,11 +41,15 @@ module Resolvers ...@@ -35,11 +41,15 @@ module Resolvers
register_instructions: instructions.register_command register_instructions: instructions.register_command
} }
ensure ensure
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'User is not authorized to register a runner for the specified resource!' if instructions.errors.include?('Gitlab::Access::AccessDeniedError') raise Gitlab::Graphql::Errors::ResourceNotAvailable, ACCESS_DENIED if access_denied?(instructions)
end end
private private
def access_denied?(instructions)
instructions.errors.include?('Gitlab::Access::AccessDeniedError')
end
def other_install_instructions(platform) def other_install_instructions(platform)
Gitlab::Ci::RunnerInstructions::OTHER_ENVIRONMENTS[platform.to_sym][:installation_instructions_url] Gitlab::Ci::RunnerInstructions::OTHER_ENVIRONMENTS[platform.to_sym][:installation_instructions_url]
end end
......
...@@ -5,8 +5,10 @@ module Resolvers ...@@ -5,8 +5,10 @@ module Resolvers
type ::GraphQL::STRING_TYPE, null: false type ::GraphQL::STRING_TYPE, null: false
description 'Testing endpoint to validate the API with' description 'Testing endpoint to validate the API with'
argument :text, GraphQL::STRING_TYPE, required: true, argument :text,
description: 'Text to echo back.' type: GraphQL::STRING_TYPE,
required: true,
description: 'Text to echo back.'
def resolve(text:) def resolve(text:)
username = current_user&.username username = current_user&.username
......
# frozen_string_literal: true # frozen_string_literal: true
# rubocop:disable Graphql/ResolverType (inherited from MilestonesResolver)
module Resolvers module Resolvers
class GroupMilestonesResolver < MilestonesResolver class GroupMilestonesResolver < MilestonesResolver
......
...@@ -5,8 +5,8 @@ module Resolvers ...@@ -5,8 +5,8 @@ module Resolvers
type Types::ProjectType.connection_type, null: true type Types::ProjectType.connection_type, null: true
argument :search, GraphQL::STRING_TYPE, argument :search, GraphQL::STRING_TYPE,
required: false, required: false,
description: 'Search query.' description: 'Search query.'
alias_method :user, :object alias_method :user, :object
......
...@@ -10,7 +10,10 @@ module Types ...@@ -10,7 +10,10 @@ module Types
argument :not, NegatedBoardIssueInputType, argument :not, NegatedBoardIssueInputType,
required: false, required: false,
description: 'List of negated params. Warning: this argument is experimental and a subject to change in future.' description: <<~MD
List of negated arguments.
Warning: this argument is experimental and a subject to change in future.
MD
argument :search, GraphQL::STRING_TYPE, argument :search, GraphQL::STRING_TYPE,
required: false, required: false,
......
...@@ -8,39 +8,65 @@ module Types ...@@ -8,39 +8,65 @@ module Types
expose_permissions Types::PermissionTypes::Group expose_permissions Types::PermissionTypes::Group
field :web_url, GraphQL::STRING_TYPE, null: false, field :web_url,
type: GraphQL::STRING_TYPE,
null: false,
description: 'Web URL of the group.' description: 'Web URL of the group.'
field :avatar_url, GraphQL::STRING_TYPE, null: true, field :avatar_url,
type: GraphQL::STRING_TYPE,
null: true,
description: 'Avatar URL of the group.' description: 'Avatar URL of the group.'
field :custom_emoji, Types::CustomEmojiType.connection_type, null: true, field :custom_emoji,
type: Types::CustomEmojiType.connection_type,
null: true,
description: 'Custom emoji within this namespace.', description: 'Custom emoji within this namespace.',
feature_flag: :custom_emoji feature_flag: :custom_emoji
field :share_with_group_lock, GraphQL::BOOLEAN_TYPE, null: true, field :share_with_group_lock,
type: GraphQL::BOOLEAN_TYPE,
null: true,
description: 'Indicates if sharing a project with another group within this group is prevented.' description: 'Indicates if sharing a project with another group within this group is prevented.'
field :project_creation_level, GraphQL::STRING_TYPE, null: true, method: :project_creation_level_str, field :project_creation_level,
type: GraphQL::STRING_TYPE,
null: true,
method: :project_creation_level_str,
description: 'The permission level required to create projects in the group.' description: 'The permission level required to create projects in the group.'
field :subgroup_creation_level, GraphQL::STRING_TYPE, null: true, method: :subgroup_creation_level_str, field :subgroup_creation_level,
type: GraphQL::STRING_TYPE,
null: true,
method: :subgroup_creation_level_str,
description: 'The permission level required to create subgroups within the group.' description: 'The permission level required to create subgroups within the group.'
field :require_two_factor_authentication, GraphQL::BOOLEAN_TYPE, null: true, field :require_two_factor_authentication,
type: GraphQL::BOOLEAN_TYPE,
null: true,
description: 'Indicates if all users in this group are required to set up two-factor authentication.' description: 'Indicates if all users in this group are required to set up two-factor authentication.'
field :two_factor_grace_period, GraphQL::INT_TYPE, null: true, field :two_factor_grace_period,
type: GraphQL::INT_TYPE,
null: true,
description: 'Time before two-factor authentication is enforced.' description: 'Time before two-factor authentication is enforced.'
field :auto_devops_enabled, GraphQL::BOOLEAN_TYPE, null: true, field :auto_devops_enabled,
type: GraphQL::BOOLEAN_TYPE,
null: true,
description: 'Indicates whether Auto DevOps is enabled for all projects within this group.' description: 'Indicates whether Auto DevOps is enabled for all projects within this group.'
field :emails_disabled, GraphQL::BOOLEAN_TYPE, null: true, field :emails_disabled,
type: GraphQL::BOOLEAN_TYPE,
null: true,
description: 'Indicates if a group has email notifications disabled.' description: 'Indicates if a group has email notifications disabled.'
field :mentions_disabled, GraphQL::BOOLEAN_TYPE, null: true, field :mentions_disabled,
type: GraphQL::BOOLEAN_TYPE,
null: true,
description: 'Indicates if a group is disabled from getting mentioned.' description: 'Indicates if a group is disabled from getting mentioned.'
field :parent, GroupType, null: true, field :parent,
type: GroupType,
null: true,
description: 'Parent group.' description: 'Parent group.'
field :issues, field :issues,
...@@ -55,7 +81,7 @@ module Types ...@@ -55,7 +81,7 @@ module Types
description: 'Merge requests for projects in this group.', description: 'Merge requests for projects in this group.',
resolver: Resolvers::GroupMergeRequestsResolver resolver: Resolvers::GroupMergeRequestsResolver
field :milestones, Types::MilestoneType.connection_type, null: true, field :milestones,
description: 'Milestones of the group.', description: 'Milestones of the group.',
resolver: Resolvers::GroupMilestonesResolver resolver: Resolvers::GroupMilestonesResolver
...@@ -76,9 +102,10 @@ module Types ...@@ -76,9 +102,10 @@ module Types
Types::LabelType, Types::LabelType,
null: true, null: true,
description: 'A label available on this group.' do description: 'A label available on this group.' do
argument :title, GraphQL::STRING_TYPE, argument :title,
required: true, type: GraphQL::STRING_TYPE,
description: 'Title of the label.' required: true,
description: 'Title of the label.'
end end
field :group_members, field :group_members,
...@@ -92,7 +119,9 @@ module Types ...@@ -92,7 +119,9 @@ module Types
resolver: Resolvers::ContainerRepositoriesResolver, resolver: Resolvers::ContainerRepositoriesResolver,
authorize: :read_container_image authorize: :read_container_image
field :container_repositories_count, GraphQL::INT_TYPE, null: false, field :container_repositories_count,
type: GraphQL::INT_TYPE,
null: false,
description: 'Number of container repositories in the group.' description: 'Number of container repositories in the group.'
field :packages, field :packages,
......
...@@ -5,12 +5,12 @@ module Types ...@@ -5,12 +5,12 @@ module Types
graphql_name 'JiraUsersMappingInputType' graphql_name 'JiraUsersMappingInputType'
argument :jira_account_id, argument :jira_account_id,
GraphQL::STRING_TYPE, GraphQL::STRING_TYPE,
required: true, required: true,
description: 'Jira account ID of the user.' description: 'Jira account ID of the user.'
argument :gitlab_id, argument :gitlab_id,
GraphQL::INT_TYPE, GraphQL::INT_TYPE,
required: false, required: false,
description: 'Id of the GitLab user.' description: 'ID of the GitLab user.'
end end
end end
...@@ -55,7 +55,10 @@ module Types ...@@ -55,7 +55,10 @@ module Types
field :container_repository, Types::ContainerRepositoryDetailsType, field :container_repository, Types::ContainerRepositoryDetailsType,
null: true, null: true,
description: 'Find a container repository.' do description: 'Find a container repository.' do
argument :id, ::Types::GlobalIDType[::ContainerRepository], required: true, description: 'The global ID of the container repository.' argument :id,
type: ::Types::GlobalIDType[::ContainerRepository],
required: true,
description: 'The global ID of the container repository.'
end end
field :package, field :package,
...@@ -72,9 +75,7 @@ module Types ...@@ -72,9 +75,7 @@ module Types
description: 'Find users.', description: 'Find users.',
resolver: Resolvers::UsersResolver resolver: Resolvers::UsersResolver
field :echo, GraphQL::STRING_TYPE, null: false, field :echo, resolver: Resolvers::EchoResolver
description: 'Text to echo back.',
resolver: Resolvers::EchoResolver
field :issue, Types::IssueType, field :issue, Types::IssueType,
null: true, null: true,
...@@ -102,18 +103,10 @@ module Types ...@@ -102,18 +103,10 @@ module Types
null: true, null: true,
description: 'CI related settings that apply to the entire instance.' description: 'CI related settings that apply to the entire instance.'
field :runner_platforms, Types::Ci::RunnerPlatformType.connection_type, field :runner_platforms, resolver: Resolvers::Ci::RunnerPlatformsResolver
null: true, description: 'Supported runner platforms.', field :runner_setup, resolver: Resolvers::Ci::RunnerSetupResolver
resolver: Resolvers::Ci::RunnerPlatformsResolver
field :runner_setup, Types::Ci::RunnerSetupType, null: true, field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1
description: 'Get runner setup instructions.',
resolver: Resolvers::Ci::RunnerSetupResolver
field :ci_config, Types::Ci::Config::ConfigType, null: true,
description: 'Get linted and processed contents of a CI config. Should not be requested more than once per request.',
resolver: Resolvers::Ci::ConfigResolver,
complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1
def design_management def design_management
DesignManagementObject.new(nil) DesignManagementObject.new(nil)
......
...@@ -11,44 +11,72 @@ module Types ...@@ -11,44 +11,72 @@ module Types
expose_permissions Types::PermissionTypes::User expose_permissions Types::PermissionTypes::User
field :id, GraphQL::ID_TYPE, null: false, field :id,
type: GraphQL::ID_TYPE,
null: false,
description: 'ID of the user.' description: 'ID of the user.'
field :bot, GraphQL::BOOLEAN_TYPE, null: false, field :bot,
type: GraphQL::BOOLEAN_TYPE,
null: false,
description: 'Indicates if the user is a bot.', description: 'Indicates if the user is a bot.',
method: :bot? method: :bot?
field :username, GraphQL::STRING_TYPE, null: false, field :username,
type: GraphQL::STRING_TYPE,
null: false,
description: 'Username of the user. Unique within this instance of GitLab.' description: 'Username of the user. Unique within this instance of GitLab.'
field :name, GraphQL::STRING_TYPE, null: false, field :name,
type: GraphQL::STRING_TYPE,
null: false,
description: 'Human-readable name of the user.' description: 'Human-readable name of the user.'
field :state, Types::UserStateEnum, null: false, field :state,
type: Types::UserStateEnum,
null: false,
description: 'State of the user.' description: 'State of the user.'
field :email, GraphQL::STRING_TYPE, null: true, field :email,
type: GraphQL::STRING_TYPE,
null: true,
description: 'User email.', method: :public_email, description: 'User email.', method: :public_email,
deprecated: { reason: :renamed, replacement: 'User.publicEmail', milestone: '13.7' } deprecated: { reason: :renamed, replacement: 'User.publicEmail', milestone: '13.7' }
field :public_email, GraphQL::STRING_TYPE, null: true, field :public_email,
type: GraphQL::STRING_TYPE,
null: true,
description: "User's public email." description: "User's public email."
field :avatar_url, GraphQL::STRING_TYPE, null: true, field :avatar_url,
type: GraphQL::STRING_TYPE,
null: true,
description: "URL of the user's avatar." description: "URL of the user's avatar."
field :web_url, GraphQL::STRING_TYPE, null: false, field :web_url,
type: GraphQL::STRING_TYPE,
null: false,
description: 'Web URL of the user.' description: 'Web URL of the user.'
field :web_path, GraphQL::STRING_TYPE, null: false, field :web_path,
type: GraphQL::STRING_TYPE,
null: false,
description: 'Web path of the user.' description: 'Web path of the user.'
field :todos, Types::TodoType.connection_type, null: false, field :todos,
resolver: Resolvers::TodoResolver, resolver: Resolvers::TodoResolver,
description: 'To-do items of the user.' description: 'To-do items of the user.'
field :group_memberships, Types::GroupMemberType.connection_type, null: true, field :group_memberships,
type: Types::GroupMemberType.connection_type,
null: true,
description: 'Group memberships of the user.' description: 'Group memberships of the user.'
field :group_count, GraphQL::INT_TYPE, null: true, field :group_count,
resolver: Resolvers::Users::GroupCountResolver, resolver: Resolvers::Users::GroupCountResolver,
description: 'Group count for the user.', description: 'Group count for the user.',
feature_flag: :user_group_counts feature_flag: :user_group_counts
field :status, Types::UserStatusType, null: true, field :status,
description: 'User status.' type: Types::UserStatusType,
field :location, ::GraphQL::STRING_TYPE, null: true, null: true,
description: 'User status.'
field :location,
type: ::GraphQL::STRING_TYPE,
null: true,
description: 'The location of the user.' description: 'The location of the user.'
field :project_memberships, Types::ProjectMemberType.connection_type, null: true, field :project_memberships,
type: Types::ProjectMemberType.connection_type,
null: true,
description: 'Project memberships of the user.' description: 'Project memberships of the user.'
field :starred_projects, Types::ProjectType.connection_type, null: true, field :starred_projects,
description: 'Projects starred by the user.', description: 'Projects starred by the user.',
resolver: Resolvers::UserStarredProjectsResolver resolver: Resolvers::UserStarredProjectsResolver
...@@ -64,8 +92,6 @@ module Types ...@@ -64,8 +92,6 @@ module Types
description: 'Merge Requests assigned to the user for review.' description: 'Merge Requests assigned to the user for review.'
field :snippets, field :snippets,
Types::SnippetType.connection_type,
null: true,
description: 'Snippets authored by the user.', description: 'Snippets authored by the user.',
resolver: Resolvers::Users::SnippetsResolver resolver: Resolvers::Users::SnippetsResolver
field :callouts, field :callouts,
......
%p.text-center %p.text-center
%span.light %span.light
Already have login and password? Already have login and password?
= link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes') - path_params = { redirect_to_referer: 'yes' }
- path_params[:invite_email] = @invite_email if @invite_email.present?
= link_to "Sign in", new_session_path(:user, path_params)
...@@ -53,10 +53,10 @@ ...@@ -53,10 +53,10 @@
%ul.dropdown-footer-list %ul.dropdown-footer-list
%li %li
- if directly_invite_members? - if directly_invite_members?
= link_to invite_text, .js-invite-members-trigger{ data: { trigger_element: 'anchor',
project_project_members_path(@project), display_text: invite_text,
title: invite_text, event: 'click_invite_members',
data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': track_label } label: track_label } }
- else - else
.js-invite-member-trigger{ data: { display_text: invite_text, event: 'click_invite_members_version_b', label: track_label } } .js-invite-member-trigger{ data: { display_text: invite_text, event: 'click_invite_members_version_b', label: track_label } }
- else - else
......
---
title: Remove deprecated repository archive routes
merge_request: 57236
author:
type: removed
---
title: Change assignee dropdown invite to utilize invite modal
merge_request: 57002
author:
type: changed
---
title: Connect Registries searches to URL
merge_request: 57251
author:
type: added
---
title: Send invited users to sign up instead of sign in when possible
merge_request: 57240
author:
type: other
---
title: Migrate group badges when using Bulk Import
merge_request: 56357
author:
type: added
...@@ -5,3 +5,4 @@ filenames: ...@@ -5,3 +5,4 @@ filenames:
- ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/create_api_fuzzing_configuration.mutation.graphql - ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/create_api_fuzzing_configuration.mutation.graphql
- ee/app/assets/javascripts/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql - ee/app/assets/javascripts/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql
- app/assets/javascripts/repository/queries/blob_info.query.graphql - app/assets/javascripts/repository/queries/blob_info.query.graphql
- ee/app/assets/javascripts/security_configuration/graphql/configure_dependency_scanning.mutation.graphql
...@@ -2,15 +2,7 @@ ...@@ -2,15 +2,7 @@
# All routing related to repository browsing # All routing related to repository browsing
resource :repository, only: [:create] do resource :repository, only: [:create]
member do
# deprecated since GitLab 9.5
get 'archive', constraints: { format: Gitlab::PathRegex.archive_formats_regex }, as: 'archive_alternative', defaults: { append_sha: true }
# deprecated since GitLab 10.7
get ':id/archive', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+/ }, action: 'archive', as: 'archive_deprecated', defaults: { append_sha: true }
end
end
resources :commit, only: [:show], constraints: { id: /\h{7,40}/ } do resources :commit, only: [:show], constraints: { id: /\h{7,40}/ } do
member do member do
......
...@@ -6,8 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w ...@@ -6,8 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# PostgreSQL replication and failover with Omnibus GitLab **(PREMIUM SELF)** # PostgreSQL replication and failover with Omnibus GitLab **(PREMIUM SELF)**
This document focuses on configuration supported with [GitLab Premium](https://about.gitlab.com/pricing/), using the Omnibus GitLab package. If you're a Free user of GitLab self-managed, consider using a cloud-hosted solution.
If you're a Community Edition or Starter user, consider using a cloud hosted solution.
This document doesn't cover installations from source. This document doesn't cover installations from source.
If a setup with replication and failover isn't what you were looking for, see If a setup with replication and failover isn't what you were looking for, see
......
...@@ -215,7 +215,7 @@ Example response: ...@@ -215,7 +215,7 @@ Example response:
## Update an issue board ## Update an issue board
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5954) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.1. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5954) in GitLab 11.1.
Updates a project issue board. Updates a project issue board.
...@@ -228,10 +228,10 @@ PUT /projects/:id/boards/:board_id ...@@ -228,10 +228,10 @@ PUT /projects/:id/boards/:board_id
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `board_id` | integer | yes | The ID of a board | | `board_id` | integer | yes | The ID of a board |
| `name` | string | no | The new name of the board | | `name` | string | no | The new name of the board |
| `assignee_id` **(STARTER)** | integer | no | The assignee the board should be scoped to | | `assignee_id` **(PREMIUM)** | integer | no | The assignee the board should be scoped to |
| `milestone_id` **(STARTER)** | integer | no | The milestone the board should be scoped to | | `milestone_id` **(PREMIUM)** | integer | no | The milestone the board should be scoped to |
| `labels` **(STARTER)** | string | no | Comma-separated list of label names which the board should be scoped to | | `labels` **(PREMIUM)** | string | no | Comma-separated list of label names which the board should be scoped to |
| `weight` **(STARTER)** | integer | no | The weight range from 0 to 9, to which the board should be scoped to | | `weight` **(PREMIUM)** | integer | no | The weight range from 0 to 9, to which the board should be scoped to |
```shell ```shell
curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/boards/1?name=new_name&milestone_id=43&assignee_id=1&labels=Doing&weight=4" curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/boards/1?name=new_name&milestone_id=43&assignee_id=1&labels=Doing&weight=4"
......
...@@ -38,7 +38,8 @@ Returns [`CiApplicationSettings`](#ciapplicationsettings). ...@@ -38,7 +38,8 @@ Returns [`CiApplicationSettings`](#ciapplicationsettings).
### `ciConfig` ### `ciConfig`
Get linted and processed contents of a CI config. Should not be requested more than once per request. Linted and processed contents of a CI config.
Should not be requested more than once per request.
Returns [`CiConfig`](#ciconfig). Returns [`CiConfig`](#ciconfig).
...@@ -93,7 +94,7 @@ Returns [`DevopsAdoptionSegmentConnection`](#devopsadoptionsegmentconnection). ...@@ -93,7 +94,7 @@ Returns [`DevopsAdoptionSegmentConnection`](#devopsadoptionsegmentconnection).
### `echo` ### `echo`
Text to echo back. Testing endpoint to validate the API with.
Returns [`String!`](#string). Returns [`String!`](#string).
...@@ -271,7 +272,7 @@ Returns [`RunnerPlatformConnection`](#runnerplatformconnection). ...@@ -271,7 +272,7 @@ Returns [`RunnerPlatformConnection`](#runnerplatformconnection).
### `runnerSetup` ### `runnerSetup`
Get runner setup instructions. Runner setup instructions.
Returns [`RunnerSetup`](#runnersetup). Returns [`RunnerSetup`](#runnersetup).
...@@ -398,7 +399,9 @@ Returns [`VulnerabilitiesCountByDayConnection`](#vulnerabilitiescountbydayconnec ...@@ -398,7 +399,9 @@ Returns [`VulnerabilitiesCountByDayConnection`](#vulnerabilitiescountbydayconnec
### `vulnerabilitiesCountByDayAndSeverity` ### `vulnerabilitiesCountByDayAndSeverity`
Number of vulnerabilities per severity level, per day, for the projects on the current user's instance security dashboard. Number of vulnerabilities per severity level, per day, for the projects on the
current user's instance security dashboard.
.
WARNING: WARNING:
**Deprecated** in 13.3. **Deprecated** in 13.3.
...@@ -6509,7 +6512,7 @@ Representation of a GitLab user. ...@@ -6509,7 +6512,7 @@ Representation of a GitLab user.
| `starredProjects` | [`ProjectConnection`](#projectconnection) | Projects starred by the user. | | `starredProjects` | [`ProjectConnection`](#projectconnection) | Projects starred by the user. |
| `state` | [`UserState!`](#userstate) | State of the user. | | `state` | [`UserState!`](#userstate) | State of the user. |
| `status` | [`UserStatus`](#userstatus) | User status. | | `status` | [`UserStatus`](#userstatus) | User status. |
| `todos` | [`TodoConnection!`](#todoconnection) | To-do items of the user. | | `todos` | [`TodoConnection`](#todoconnection) | To-do items of the user. |
| `userPermissions` | [`UserPermissions!`](#userpermissions) | Permissions for the current user on the resource. | | `userPermissions` | [`UserPermissions!`](#userpermissions) | Permissions for the current user on the resource. |
| `username` | [`String!`](#string) | Username of the user. Unique within this instance of GitLab. | | `username` | [`String!`](#string) | Username of the user. Unique within this instance of GitLab. |
| `webPath` | [`String!`](#string) | Web path of the user. | | `webPath` | [`String!`](#string) | Web path of the user. |
......
...@@ -183,20 +183,52 @@ file. ...@@ -183,20 +183,52 @@ file.
To add a redirect: To add a redirect:
1. Create a merge request in one of the internal docs projects (`gitlab`, 1. In the repository (`gitlab`, `gitlab-runner`, `omnibus-gitlab`, or `charts`),
`gitlab-runner`, `omnibus-gitlab`, or `charts`), depending on the location of create a new documentation file. Don't delete the old one. The easiest
the file that's being moved, renamed, or removed. way is to copy it. For example:
1. To move or rename the documentation file, create a new file with the new
name or location, but don't delete the existing documentation file. ```shell
1. In the original documentation file, add the redirect code for cp doc/user/search/old_file.md doc/api/new_file.md
`/help`. Use the following template exactly, and change only the links and ```
date. Use relative paths and `.md` for a redirect to another documentation
page. Use the full URL (with `https://`) to redirect to a different project or 1. Add the redirect code to the old documentation file by running the
site: following Rake task. The first argument is the path of the old file,
and the second argument is the path of the new file:
- To redirect to a page in the same project, use relative paths and
the `.md` extension. Both old and new paths start from the same location.
In the following example, both paths are relative to `doc/`:
```shell
bundle exec rake "gitlab:docs:redirect[doc/user/search/old_file.md, doc/api/new_file.md]"
```
- To redirect to a page in a different project or site, use the full URL (with `https://`) :
```shell
bundle exec rake "gitlab:docs:redirect[doc/user/search/old_file.md, https://example.com]"
```
Alternatively, you can omit the arguments, and you'll be asked to enter
their values:
```shell
bundle exec rake gitlab:docs:redirect
```
If you don't want to use the Rake task, you can use the following template.
However, the file paths must be relative to the `doc` or `docs` directory.
Replace the value of `redirect_to` with the new file path and `YYYY-MM-DD`
with the date the file should be removed.
Redirect files that link to docs in internal documentation projects
are removed after three months. Redirect files that link to external sites are
removed after one year:
```markdown ```markdown
--- ---
redirect_to: '../path/to/file/index.md' redirect_to: '../newpath/to/file/index.md'
--- ---
This document was moved to [another location](../path/to/file/index.md). This document was moved to [another location](../path/to/file/index.md).
...@@ -205,27 +237,24 @@ To add a redirect: ...@@ -205,27 +237,24 @@ To add a redirect:
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page --> <!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->
``` ```
Redirect files linking to docs in any of the internal documentations projects
are removed after three months. Redirect files linking to external sites are
removed after one year.
1. If the documentation page being moved has any Disqus comments, follow the steps 1. If the documentation page being moved has any Disqus comments, follow the steps
described in [Redirections for pages with Disqus comments](#redirections-for-pages-with-disqus-comments). described in [Redirections for pages with Disqus comments](#redirections-for-pages-with-disqus-comments).
1. If a documentation page you're removing includes images that aren't used 1. Open a merge request with your changes. If a documentation page
you're removing includes images that aren't used
with any other documentation pages, be sure to use your merge request to delete with any other documentation pages, be sure to use your merge request to delete
those images from the repository. those images from the repository.
1. Assign the merge request to a technical writer for review and merge. 1. Assign the merge request to a technical writer for review and merge.
1. Search for links to the original documentation file. You must find and update all 1. Search for links to the old documentation file. You must find and update all
links that point to the original documentation file: links that point to the old documentation file:
- In <https://gitlab.com/gitlab-com/www-gitlab-com>, search for full URLs: - In <https://gitlab.com/gitlab-com/www-gitlab-com>, search for full URLs:
`grep -r "docs.gitlab.com/ee/path/to/file.html" .` `grep -r "docs.gitlab.com/ee/path/to/file.html" .`
- In <https://gitlab.com/gitlab-org/gitlab-docs/-/tree/master/content/_data>, - In <https://gitlab.com/gitlab-org/gitlab-docs/-/tree/master/content/_data>,
search the navigation bar configuration files for the path with `.html`: search the navigation bar configuration files for the path with `.html`:
`grep -r "path/to/file.html" .` `grep -r "path/to/file.html" .`
- In any of the four internal projects. This includes searching for links in the docs - In any of the four internal projects, search for links in the docs
and codebase. Search for all variations, including full URL and just the path. and codebase. Search for all variations, including full URL and just the path.
In macOS for example, go to the root directory of the `gitlab` project and run: For example, go to the root directory of the `gitlab` project and run:
```shell ```shell
grep -r "docs.gitlab.com/ee/path/to/file.html" . grep -r "docs.gitlab.com/ee/path/to/file.html" .
......
...@@ -302,7 +302,7 @@ end ...@@ -302,7 +302,7 @@ end
Adding foreign key to `projects`: Adding foreign key to `projects`:
We can use the `add_concurrenct_foreign_key` method in this case, as this helper method We can use the `add_concurrent_foreign_key` method in this case, as this helper method
has the lock retries built into it. has the lock retries built into it.
```ruby ```ruby
......
...@@ -22,13 +22,13 @@ a black-box testing framework for the API and the UI. ...@@ -22,13 +22,13 @@ a black-box testing framework for the API and the UI.
### Testing nightly builds ### Testing nightly builds
We run scheduled pipelines each night to test nightly builds created by Omnibus. We run scheduled pipelines each night to test nightly builds created by Omnibus.
You can find these nightly pipelines at `https://gitlab.com/gitlab-org/quality/nightly/pipelines` You can find these pipelines at <https://gitlab.com/gitlab-org/quality/nightly/pipelines>
(need Developer access permissions). Results are reported in the `#qa-nightly` Slack channel. (need Developer access permissions). Results are reported in the `#qa-nightly` Slack channel.
### Testing staging ### Testing staging
We run scheduled pipelines each night to test staging. We run scheduled pipelines each night to test staging.
You can find these nightly pipelines at `https://gitlab.com/gitlab-org/quality/staging/pipelines` You can find these pipelines at <https://gitlab.com/gitlab-org/quality/staging/pipelines>
(need Developer access permissions). Results are reported in the `#qa-staging` Slack channel. (need Developer access permissions). Results are reported in the `#qa-staging` Slack channel.
### Testing code in merge requests ### Testing code in merge requests
...@@ -36,64 +36,63 @@ You can find these nightly pipelines at `https://gitlab.com/gitlab-org/quality/s ...@@ -36,64 +36,63 @@ You can find these nightly pipelines at `https://gitlab.com/gitlab-org/quality/s
#### Using the `package-and-qa` job #### Using the `package-and-qa` job
It is possible to run end-to-end tests for a merge request, eventually being run in It is possible to run end-to-end tests for a merge request, eventually being run in
a pipeline in the [`gitlab-qa-mirror`](https://gitlab.com/gitlab-org/gitlab-qa-mirror/) project, a pipeline in the [`gitlab-org/gitlab-qa-mirror`](https://gitlab.com/gitlab-org/gitlab-qa-mirror) project,
by triggering the `package-and-qa` manual action in the `test` stage (not by triggering the `package-and-qa` manual action in the `qa` stage (not
available for forks). available for forks).
**This runs end-to-end tests against a custom CE and EE (with an Ultimate license) **This runs end-to-end tests against a custom EE (with an Ultimate license)
Omnibus package built from your merge request's changes.** Docker image built from your merge request's changes.**
Manual action that starts end-to-end tests is also available in merge requests Manual action that starts end-to-end tests is also available
in [Omnibus GitLab](https://gitlab.com/gitlab-org/omnibus-gitlab). in [`gitlab-org/omnibus-gitlab` merge requests](https://docs.gitlab.com/omnibus/build/team_member_docs.html#i-have-an-mr-in-the-omnibus-gitlab-project-and-want-a-package-or-docker-image-to-test-it).
Below you can read more about how to use it and how does it work.
#### How does it work? #### How does it work?
Currently, we are using _multi-project pipeline_-like approach to run QA Currently, we are using _multi-project pipeline_-like approach to run end-to-end
pipelines. pipelines.
```mermaid ```mermaid
graph LR graph TB
A1 -.->|1. Triggers an omnibus-gitlab-mirror pipeline and wait for it to be done| A2 A1 -.->|once done, can be triggered| A2
B2[`Trigger-qa` stage<br>`Trigger:qa-test` job] -.->|2. Triggers a gitlab-qa-mirror pipeline and wait for it to be done| A3 A2 -.->|1. Triggers an `omnibus-gitlab-mirror` pipeline<br>and wait for it to be done| B1
B2[`Trigger-qa` stage<br>`Trigger:qa-test` job] -.->|2. Triggers a `gitlab-qa-mirror` pipeline<br>and wait for it to be done| C1
subgraph "gitlab-foss/gitlab pipeline"
A1[`test` stage<br>`package-and-qa` job] subgraph "`gitlab-org/gitlab` pipeline"
A1[`build-images` stage<br>`build-qa-image` and `build-assets-image` jobs]
A2[`qa` stage<br>`package-and-qa` job]
end end
subgraph "omnibus-gitlab pipeline" subgraph "`gitlab-org/build/omnibus-gitlab-mirror` pipeline"
A2[`Trigger-docker` stage<br>`Trigger:gitlab-docker` job] -->|once done| B2 B1[`Trigger-docker` stage<br>`Trigger:gitlab-docker` job] -->|once done| B2
end end
subgraph "gitlab-qa-mirror pipeline" subgraph "`gitlab-org/gitlab-qa-mirror` pipeline"
A3>QA jobs run] -.->|3. Reports back the pipeline result to the `package-and-qa` job<br>and post the result on the original commit tested| A1 C1>End-to-end jobs run]
end end
``` ```
1. Developer triggers a manual action, that can be found in GitLab merge 1. In the [`gitlab-org/gitlab` pipeline](https://gitlab.com/gitlab-org/gitlab):
requests. This starts a chain of pipelines in multiple projects. 1. Developer triggers the `package-and-qa` manual action (available once the `build-qa-image` and
`build-assets-image` jobs are done), that can be found in GitLab merge
1. The script being executed triggers a pipeline in requests. This starts a chain of pipelines in multiple projects.
[Omnibus GitLab Mirror](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror) 1. The script being executed triggers a pipeline in
and waits for the resulting status. We call this a _status attribution_. [`gitlab-org/build/omnibus-gitlab-mirror`](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror)
and polls for the resulting status. We call this a _status attribution_.
1. GitLab packages are being built in the [Omnibus GitLab Mirror](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror)
pipeline. Packages are then pushed to its Container Registry.
1. When packages are ready, and available in the registry, a final step in the 1. In the [`gitlab-org/build/omnibus-gitlab-mirror` pipeline](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror):
[Omnibus GitLab Mirror](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror) pipeline, triggers a new 1. Docker image is being built and pushed to its Container Registry.
GitLab QA pipeline (those with access can view them at `https://gitlab.com/gitlab-org/gitlab-qa-mirror/pipelines`). It also waits for a resulting status. 1. Finally, the `Trigger:qa-test` job triggers a new end-to-end pipeline in
[`gitlab-org/gitlab-qa-mirror`](https://gitlab.com/gitlab-org/gitlab-qa-mirror/pipelines) and polls for the resulting status.
1. GitLab QA pulls images from the registry, spins-up containers and runs tests 1. In the [`gitlab-org/gitlab-qa-mirror` pipeline](https://gitlab.com/gitlab-org/gitlab-qa-mirror):
against a test environment that has been just orchestrated by the `gitlab-qa` 1. Container for the Docker image stored in the [`gitlab-org/build/omnibus-gitlab-mirror`](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror) registry is spun-up.
tool. 1. End-to-end tests are run with the `gitlab-qa` executable, which spin up a container for the end-to-end image from the [`gitlab-org/gitlab`](https://gitlab.com/gitlab-org/gitlab) registry.
1. The result of the GitLab QA pipeline is being 1. The result of the [`gitlab-org/gitlab-qa-mirror` pipeline](https://gitlab.com/gitlab-org/gitlab-qa-mirror) is being
propagated upstream, through Omnibus, back to the GitLab merge request. propagated upstream (through polling from upstream pipelines), through [`gitlab-org/build/omnibus-gitlab-mirror`](https://gitlab.com/gitlab-org/build/omnibus-gitlab-mirror), back to the [`gitlab-org/gitlab`](https://gitlab.com/gitlab-org/gitlab) merge request.
Please note, we plan to [add more specific information](https://gitlab.com/gitlab-org/quality/team-tasks/-/issues/156) Please note, we plan to [add more specific information](https://gitlab.com/gitlab-org/quality/team-tasks/-/issues/156)
about the tests included in each job/scenario that runs in `gitlab-qa-mirror`. about the tests included in each job/scenario that runs in `gitlab-org/gitlab-qa-mirror`.
#### With Pipeline for Merged Results #### With Pipeline for Merged Results
......
...@@ -978,7 +978,7 @@ Example aggregated metric entries: ...@@ -978,7 +978,7 @@ Example aggregated metric entries:
```yaml ```yaml
- name: example_metrics_union - name: example_metrics_union
operator: OR operator: OR
events: events:
- 'i_search_total' - 'i_search_total'
- 'i_search_advanced' - 'i_search_advanced'
- 'i_search_paid' - 'i_search_paid'
...@@ -1362,4 +1362,24 @@ bin/rake gitlab:usage_data:dump_sql_in_yaml > ~/Desktop/usage-metrics-2020-09-02 ...@@ -1362,4 +1362,24 @@ bin/rake gitlab:usage_data:dump_sql_in_yaml > ~/Desktop/usage-metrics-2020-09-02
## Generating and troubleshooting usage ping ## Generating and troubleshooting usage ping
To get a usage ping, or to troubleshoot caching issues on your GitLab instance, please follow [instructions to generate usage ping](../../administration/troubleshooting/gitlab_rails_cheat_sheet.md#generate-usage-ping). This activity is to be done via a detached screen session on a remote server.
Before you begin these steps, make sure the key is added to the SSH agent locally
with the `ssh-add` command.
### Triggering
1. Connect to bastion with agent forwarding: `$ ssh -A lb-bastion.gprd.gitlab.com`
1. Create named screen: `$ screen -S <username>_usage_ping_<date>`
1. Connect to console host: `$ ssh $USER-rails@console-01-sv-gprd.c.gitlab-production.internal`
1. Run `SubmitUsagePingService.new.execute`
1. Detach from screen: `ctrl + a, ctrl + d`
1. Exit from bastion: `$ exit`
### Verification (After approx 30 hours)
1. Reconnect to bastion: `$ ssh -A lb-bastion.gprd.gitlab.com`
1. Find your screen session: `$ screen -ls`
1. Attach to your screen session: `$ screen -x 14226.mwawrzyniak_usage_ping_2021_01_22`
1. Check the last payload in `raw_usage_data` table: `RawUsageData.last.payload`
1. Check the when the payload was sent: `RawUsageData.last.sent_at`
...@@ -6,93 +6,93 @@ info: To determine the technical writer assigned to the Stage/Group associated w ...@@ -6,93 +6,93 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Jira DVCS connector # Jira DVCS connector
If you're using GitLab.com and Jira Cloud, use the Use the Jira DVCS (distributed version control system) connector if you self-host
[GitLab for Jira app](connect-app.md) unless you have a specific need for the DVCS Connector. either your Jira instance or your GitLab instance, and you want to sync information
between them. If you use Jira Cloud and GitLab.com, you should use the
[GitLab for Jira app](connect-app.md) unless you specifically need the DVCS connector.
When configuring Jira DVCS Connector: When you configure the Jira DVCS connector, make sure your GitLab and Jira instances
are accessible.
- If you are using self-managed GitLab, make sure your GitLab instance is accessible by Jira. - **Self-managed GitLab**: Your GitLab instance must be accessible by Jira.
- If you're connecting to Jira Cloud, ensure your instance is accessible through the internet. - **Jira Cloud**: Your instance must be accessible through the internet.
- If you are using Jira Server, make sure your instance is accessible however your network is set up. - **Jira Server**: Your network must allow access to your instance.
## GitLab account configuration for DVCS ## Configure a GitLab application for DVCS
NOTE: We recommend you create and use a `jira` user in GitLab, and use the account only
To ensure that regular user account maintenance doesn't impact your integration, for integration work. A separate account ensures regular account maintenance does not affect
create and use a single-purpose `jira` user in GitLab. your integration.
1. In GitLab, create a new application to allow Jira to connect with your GitLab account. 1. In GitLab, [create a user](../../user/profile/account/create_accounts.md) for Jira to
1. Sign in to the GitLab account that you want Jira to use to connect to GitLab. use to connect to GitLab. For Jira to access all projects,
1. In the top-right corner, select your avatar. a user with [Administrator](../../user/permissions.md) permissions must
1. Select **Edit profile**. create the user.
1. In the top right corner, click the account's avatar, and select **Edit profile**.
1. In the left sidebar, select **Applications**. 1. In the left sidebar, select **Applications**.
1. In the **Name** field, enter a descriptive name for the integration, such as `Jira`. 1. In the **Name** field, enter a descriptive name for the integration, such as `Jira`.
1. In the **Redirect URI** field, enter `https://<gitlab.example.com>/login/oauth/callback`, 1. In the **Redirect URI** field, enter the URI appropriate for your version of GitLab,
replacing `<gitlab.example.com>` with your GitLab instance domain. For example, if you are using GitLab.com, replacing `<gitlab.example.com>` with your GitLab instance domain:
this would be `https://gitlab.com/login/oauth/callback`. - *For GitLab versions 11.3 and later,* use `https://<gitlab.example.com>/login/oauth/callback`.
If you use GitLab.com, the URL is `https://gitlab.com/login/oauth/callback`.
NOTE: - *For GitLab versions 11.2 and earlier,* use
If using a GitLab version earlier than 11.3, the `Redirect URI` must be `https://<gitlab.example.com>/-/jira/login/oauth/callback`.
`https://<gitlab.example.com>/-/jira/login/oauth/callback`. If you want Jira
to have access to all projects, GitLab recommends that an administrator create the 1. For **Scopes**, select `api` and clear any other checkboxes.
application. 1. Select **Submit**.
1. GitLab displays the generated **Application ID**
![GitLab application setup](img/jira_dev_panel_gl_setup_1.png) and **Secret** values. Copy these values, as you need them to configure Jira.
1. Check **API** in the **Scopes** section, and clear any other checkboxes. ## Configure Jira for DVCS
1. Click **Save application**. GitLab displays the generated **Application ID**
and **Secret** values. Copy these values, which you use in Jira. If you use Jira Cloud and GitLab.com, use the [GitLab for Jira app](connect-app.md)
unless you specifically need the DVCS Connector.
## Jira DVCS Connector setup
Configure this connection when you want to import all GitLab commits and branches,
If you're using GitLab.com and Jira Cloud, use the for the groups you specify, into Jira. This import takes a few minutes and, after
[GitLab for Jira app](connect-app.md) unless you have a specific need for the DVCS Connector. it completes, refreshes every 60 minutes:
1. Ensure you have completed the [GitLab configuration](#gitlab-account-configuration-for-dvcs). 1. Ensure you have completed the [GitLab configuration](#configure-a-gitlab-application-for-dvcs).
1. If you're using Jira Server, go to **Settings (gear) > Applications > DVCS accounts**. 1. Go to your DVCS account:
If you're using Jira Cloud, go to **Settings (gear) > Products > DVCS accounts**. - *For Jira Server,* go to **Settings (gear) > Applications > DVCS accounts**.
1. Click **Link GitHub Enterprise account** to start creating a new integration. - *For Jira Cloud,* go to **Settings (gear) > Products > DVCS accounts**.
(We're pretending to be GitHub in this integration, until there's additional platform support in Jira.) 1. To create a new integration, select the appropriate value for **Host**:
1. Complete the form: - *For Jira versions 8.14 and later:* Select **GitLab** or
<!-- vale gitlab.Substitutions = NO -->
1. Select **GitHub Enterprise** for the **Host** field. **GitLab Self-Hosted**.
<!-- vale gitlab.Substitutions = YES -->
1. In the **Team or User Account** field, enter either: - *For Jira versions 8.13 and earlier:* Select **GitHub Enterprise**.
1. For **Team or User Account**, enter either:
- The relative path of a top-level GitLab group that you have access to. - The relative path of a top-level GitLab group that you have access to.
- The relative path of your personal namespace. - The relative path of your personal namespace.
![Creation of Jira DVCS integration](img/jira_dev_panel_jira_setup_2.png) 1. In the **Host URL** field, enter the URI appropriate for your version of GitLab,
replacing `<gitlab.example.com>` with your GitLab instance domain:
1. In the **Host URL** field, enter `https://<gitlab.example.com>/`, - *For GitLab versions 11.3 and later,* use `https://<gitlab.example.com>/`.
replacing `<gitlab.example.com>` with your GitLab instance domain. For example, if you are using GitLab.com, - *For GitLab versions 11.2 and earlier,* use
this would be `https://gitlab.com/`. `https://<gitlab.example.com>/-/jira`.
NOTE:
If using a GitLab version earlier than 11.3 the **Host URL** value should be `https://<gitlab.example.com>/-/jira`
1. For the **Client ID** field, use the **Application ID** value from the previous section.
1. For the **Client Secret** field, use the **Secret** value from the previous section.
1. For **Client ID**, use the **Application ID** value from the previous section.
1. For **Client Secret**, use the **Secret** value from the previous section.
1. Ensure that the rest of the checkboxes are checked. 1. Ensure that the rest of the checkboxes are checked.
1. Select **Add** to complete and create the integration.
1. Click **Add** to complete and create the integration. To connect additional GitLab projects from other GitLab top-level groups, or
personal namespaces, repeat the previous steps with additional Jira DVCS accounts.
Jira takes up to a few minutes to know about (import behind the scenes) all the commits and branches
for all the projects in the GitLab group you specified in the previous step. These are refreshed
every 60 minutes.
In the future, we plan on implementing real-time integration. If you need After you configure the integration, read more about [how to test and use it](index.md#usage).
to refresh the data manually, you can do this from the `Applications -> DVCS
accounts` screen where you initially set up the integration:
![Refresh GitLab information in Jira](img/jira_dev_panel_manual_refresh.png) ## Refresh data imported to Jira
To connect additional GitLab projects from other GitLab top-level groups (or personal namespaces), repeat the previous Jira imports the commits and branches every 60 minutes for your projects. You
steps with additional Jira DVCS accounts. can refresh the data manually from the Jira interface:
Now that the integration is configured, read more about how to test and use it in [Usage](index.md#usage). 1. Sign in to your Jira instance as the user you configured the integration with.
1. Go to **Settings (gear) > Applications**.
1. Select **DVCS accounts**.
1. In the table, for the repository you want to refresh, in the **Last Activity**
column, select the icon:
![Refresh GitLab information in Jira](img/jira_dev_panel_manual_refresh.png)
## Troubleshooting your DVCS connection ## Troubleshooting your DVCS connection
...@@ -100,39 +100,46 @@ Refer to the items in this section if you're having problems with your DVCS conn ...@@ -100,39 +100,46 @@ Refer to the items in this section if you're having problems with your DVCS conn
### Jira cannot access GitLab server ### Jira cannot access GitLab server
If you complete the **Add New Account** form, authorize access, and you receive
this error, Jira and GitLab cannot connect. No other error messages
appear in any logs:
```plaintext ```plaintext
Error obtaining access token. Cannot access https://gitlab.example.com from Jira. Error obtaining access token. Cannot access https://gitlab.example.com from Jira.
``` ```
This error message is generated in Jira, after completing the **Add New Account** ### SSL and TLS problems
form and authorizing access. It indicates a connectivity issue from Jira to
GitLab. No other error messages appear in any logs.
If there was an issue with SSL/TLS, this error message is generated. Problems with SSL and TLS can cause this error message:
```plaintext
Error obtaining access token. Cannot access https://gitlab.example.com from Jira.
```
- The [GitLab Jira integration](../../user/project/integrations/jira.md) requires GitLab to connect to Jira. Any - The [GitLab Jira integration](../../user/project/integrations/jira.md) requires
TLS issues that arise from a private certificate authority or self-signed GitLab to connect to Jira. Any TLS issues that arise from a private certificate
certificate [are resolved on the GitLab server](https://docs.gitlab.com/omnibus/settings/ssl.html#other-certificate-authorities), authority or self-signed certificate are resolved
[on the GitLab server](https://docs.gitlab.com/omnibus/settings/ssl.html#other-certificate-authorities),
as GitLab is the TLS client. as GitLab is the TLS client.
- The Jira Development panel integration requires Jira to connect to GitLab, which - The Jira Development panel integration requires Jira to connect to GitLab, which
causes Jira to be the TLS client. If your GitLab server's certificate is not causes Jira to be the TLS client. If your GitLab server's certificate is not
issued by a public certificate authority, the Java Truststore on Jira's server issued by a public certificate authority, the Java Truststore on Jira's server
needs to have the appropriate certificate added to it (such as your organization's must have the appropriate certificate (such as your organization's
root certificate). root certificate) added to it .
Refer to Atlassian's documentation and Atlassian Support for assistance setting up Jira correctly: Refer to Atlassian's documentation and Atlassian Support for assistance setting up Jira correctly:
- [Adding a certificate to the trust store](https://confluence.atlassian.com/kb/how-to-import-a-public-ssl-certificate-into-a-jvm-867025849.html). - [Add a certificate](https://confluence.atlassian.com/kb/how-to-import-a-public-ssl-certificate-into-a-jvm-867025849.html)
- Simplest approach is to use [`keytool`](https://docs.oracle.com/javase/8/docs/technotes/tools/unix/keytool.html). to the trust store.
- The simplest approach is [`keytool`](https://docs.oracle.com/javase/8/docs/technotes/tools/unix/keytool.html).
- Add additional roots to Java's default Truststore (`cacerts`) to allow Jira to - Add additional roots to Java's default Truststore (`cacerts`) to allow Jira to
also trust public certificate authorities. also trust public certificate authorities.
- If the integration stops working after upgrading Jira's Java runtime, this - If the integration stops working after upgrading Jira's Java runtime, the
might be because the `cacerts` Truststore got replaced. `cacerts` Truststore may have been replaced during the upgrade.
- [Troubleshooting connectivity up to and including TLS handshaking](https://confluence.atlassian.com/kb/unable-to-connect-to-ssl-services-due-to-pkix-path-building-failed-error-779355358.html), - Troubleshooting connectivity [up to and including TLS handshaking](https://confluence.atlassian.com/kb/unable-to-connect-to-ssl-services-due-to-pkix-path-building-failed-error-779355358.html),
using the a java class called `SSLPoke`. using the a java class called `SSLPoke`.
- Download the class from Atlassian's knowledge base to a directory on Jira's server, such as `/tmp`.
- Download the class from Atlassian's knowledge base to Jira's server, for example to `/tmp`.
- Use the same Java runtime as Jira. - Use the same Java runtime as Jira.
- Pass all networking-related parameters that Jira is called with, such as proxy - Pass all networking-related parameters that Jira is called with, such as proxy
settings or an alternative root Truststore (`-Djavax.net.ssl.trustStore`): settings or an alternative root Truststore (`-Djavax.net.ssl.trustStore`):
...@@ -154,38 +161,42 @@ The requested scope is invalid, unknown, or malformed. ...@@ -154,38 +161,42 @@ The requested scope is invalid, unknown, or malformed.
Potential resolutions: Potential resolutions:
- Verify the URL shown in the browser after being redirected from Jira in step 5 of [Jira DVCS Connector Setup](#jira-dvcs-connector-setup) includes `scope=api` in the query string. 1. Verify that the URL shown in the browser after being redirected from Jira in the
- If `scope=api` is missing from the URL, return to [GitLab account configuration](#gitlab-account-configuration-for-dvcs) and ensure the application you created in step 1 has the `api` box checked under scopes. [Jira DVCS connector setup](#configure-jira-for-dvcs) includes `scope=api` in
the query string.
1. If `scope=api` is missing from the URL, edit the
[GitLab account configuration](#configure-a-gitlab-application-for-dvcs). Review
the **Scopes** field and ensure the `api` check box is selected.
### Jira error adding account and no repositories listed ### Jira error adding account and no repositories listed
```plaintext After you complete the **Add New Account** form in Jira and authorize access, you might
Error! encounter these issues:
Failed adding the account: [Error retrieving list of repositories]
```
This error message is generated in Jira after completing the **Add New Account** - An `Error! Failed adding the account: [Error retrieving list of repositories]` error.
form in Jira and authorizing access. Attempting to click **Try Again** returns - An `Account is already integrated with JIRA` error when you click **Try Again**.
`Account is already integrated with JIRA.` The account is set up in the DVCS - An account is visible in the DVCS accounts view, but no repositories are listed.
accounts view, but no repositories are listed.
Potential resolutions: To resolve this issue:
- If you're using GitLab versions 11.10-12.7, upgrade to GitLab 12.8.10 or later - If you're using GitLab Free or GitLab Starter, be sure you're using
to resolve an identified [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/37012). GitLab 13.4 or later.
- If you're using GitLab Free, be sure you're using GitLab 13.4 or later. - *If you're using GitLab versions 11.10-12.7,* upgrade to GitLab 12.8.10 or later
to resolve [an identified issue](https://gitlab.com/gitlab-org/gitlab/-/issues/37012).
[Contact GitLab Support](https://about.gitlab.com/support/) if none of these reasons apply. [Contact GitLab Support](https://about.gitlab.com/support/) if none of these reasons apply.
### Fixing synchronization issues ### Fix synchronization issues
If Jira displays incorrect information (such as deleted branches), you may need to If Jira displays incorrect information, such as deleted branches, you may need to
resynchronize the information. To do so: resynchronize the information. To do so:
1. In Jira, go to **Jira Administration > Applications > DVCS accounts**. 1. In Jira, go to **Jira Administration > Applications > DVCS accounts**.
1. At the account (group or subgroup) level, Jira displays an option to 1. At the account (group or subgroup) level, Jira displays an option to
**Refresh repositories** in the `...` (ellipsis) menu. **Refresh repositories** in the **{ellipsis_h}** (ellipsis) menu.
1. For each project, there's a sync button displayed next to the **last activity** date. 1. For each project, there's a sync button displayed next to the **last activity** date.
To perform a *soft resync*, click the button, or complete a *full sync* by shift clicking - To perform a *soft resync*, click the button.
the button. For more information, see - To complete a *full sync*, shift-click the button.
[Atlassian's documentation](https://support.atlassian.com/jira-cloud-administration/docs/synchronize-jira-cloud-to-bitbucket/).
For more information, read
[Atlassian's documentation](https://support.atlassian.com/jira-cloud-administration/docs/synchronize-jira-cloud-to-bitbucket/).
...@@ -18,10 +18,10 @@ to use this endpoint. ...@@ -18,10 +18,10 @@ to use this endpoint.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/245331) in GitLab Free 13.5. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/245331) in GitLab Free 13.5.
With Maintainer or higher [permissions](../../user/permissions.md), you can view With Maintainer or higher [permissions](../../user/permissions.md),
the list of configured alerts integrations by navigating to you can view the list of configured alerts integrations by navigating to **Settings > Operations**
**Settings > Operations** in your project's sidebar menu, and expanding **Alerts** section. in your project's sidebar menu, and expanding the **Alert integrations** section. The list displays
The list displays the integration name, type, and status (enabled or disabled): the integration name, type, and status (enabled or disabled):
![Current Integrations](img/integrations_list_v13_5.png) ![Current Integrations](img/integrations_list_v13_5.png)
...@@ -39,9 +39,11 @@ receive alert payloads in JSON format. You can always ...@@ -39,9 +39,11 @@ receive alert payloads in JSON format. You can always
1. Sign in to GitLab as a user with maintainer [permissions](../../user/permissions.md) 1. Sign in to GitLab as a user with maintainer [permissions](../../user/permissions.md)
for a project. for a project.
1. Navigate to **Settings > Operations** in your project. 1. Navigate to **Settings > Operations** in your project.
1. Expand the **Alerts** section, and in the **Integration** dropdown menu, select **Generic**. 1. Expand the **Alert integrations** section, and in the **Select integration type** dropdown menu,
1. Toggle the **Active** alert setting to display the **URL** and **Authorization Key** select **HTTP Endpoint**.
for the webhook configuration. 1. Toggle the **Active** alert setting. The URL and Authorization Key for the webhook configuration
are available in the **View credentials** tab after you save the integration. You must also input
the URL and Authorization Key in your external service.
### HTTP Endpoints **(PREMIUM)** ### HTTP Endpoints **(PREMIUM)**
...@@ -54,11 +56,11 @@ and you can [customize the payload](#customize-the-alert-payload-outside-of-gitl ...@@ -54,11 +56,11 @@ and you can [customize the payload](#customize-the-alert-payload-outside-of-gitl
1. Sign in to GitLab as a user with maintainer [permissions](../../user/permissions.md) 1. Sign in to GitLab as a user with maintainer [permissions](../../user/permissions.md)
for a project. for a project.
1. Navigate to **Settings > Operations** in your project. 1. Navigate to **Settings > Operations** in your project.
1. Expand the **Alerts** section. 1. Expand the **Alert integrations** section.
1. For each endpoint you want to create: 1. For each endpoint you want to create:
1. Click the **Add new integration** button. 1. Click the **Add new integration** button.
1. In the **Integration** dropdown menu, select **HTTP Endpoint**. 1. In the **Select integration type** dropdown menu, select **HTTP Endpoint**.
1. Name the integration. 1. Name the integration.
1. Toggle the **Active** alert setting. The **URL** and **Authorization Key** for the webhook 1. Toggle the **Active** alert setting. The **URL** and **Authorization Key** for the webhook
configuration are available in the **View credentials** tab after you save the integration. configuration are available in the **View credentials** tab after you save the integration.
...@@ -85,7 +87,7 @@ correct information in the [Alert list](alerts.md) and the ...@@ -85,7 +87,7 @@ correct information in the [Alert list](alerts.md) and the
[Alert Details page](alerts.md#alert-details-page), map your alert's fields to [Alert Details page](alerts.md#alert-details-page), map your alert's fields to
GitLab fields when you [create an HTTP endpoint](#http-endpoints): GitLab fields when you [create an HTTP endpoint](#http-endpoints):
![Alert Management List](img/custom_alert_mapping_v13_10.png) ![Alert Management List](img/custom_alert_mapping_v13_11.png)
### External Prometheus integration ### External Prometheus integration
...@@ -165,9 +167,11 @@ alert to confirm your integration works properly. ...@@ -165,9 +167,11 @@ alert to confirm your integration works properly.
1. Sign in as a user with Developer or greater [permissions](../../user/permissions.md). 1. Sign in as a user with Developer or greater [permissions](../../user/permissions.md).
1. Navigate to **Settings > Operations** in your project. 1. Navigate to **Settings > Operations** in your project.
1. Click **Alerts endpoint** to expand the section. 1. Click **Alert integrations** to expand the section.
1. Enter a sample payload in **Alert test payload** (valid JSON is required). 1. Click the **{settings}** settings icon on the right side of the integration in [the list](#integrations-list).
1. Click **Test alert payload**. 1. Select the **Send test alert** tab to open it.
1. Enter a test payload in the payload field (valid JSON is required).
1. Click **Send**.
GitLab displays an error or success message, depending on the outcome of your test. GitLab displays an error or success message, depending on the outcome of your test.
......
...@@ -13,7 +13,8 @@ responsibilities. Maintain the availability of your software services by putting ...@@ -13,7 +13,8 @@ responsibilities. Maintain the availability of your software services by putting
With an on-call schedule, your team is notified immediately when things go wrong so they can quickly With an on-call schedule, your team is notified immediately when things go wrong so they can quickly
respond to service outages and disruptions. respond to service outages and disruptions.
To use on-call schedules, you must do the following: To use on-call schedules, users with Maintainer [permissions](../../user/permissions.md)
must do the following:
1. [Create a schedule](#schedules). 1. [Create a schedule](#schedules).
1. [Add a rotation to the schedule](#rotations). 1. [Add a rotation to the schedule](#rotations).
......
...@@ -221,7 +221,7 @@ to set the status for each alert: ...@@ -221,7 +221,7 @@ to set the status for each alert:
By default, the list doesn't display resolved or dismissed alerts. To show these alerts, clear the By default, the list doesn't display resolved or dismissed alerts. To show these alerts, clear the
checkbox **Hide dismissed alerts**. checkbox **Hide dismissed alerts**.
![Policy Alert List](img/threat_monitoring_policy_alert_list_v13_9.png) ![Policy Alert List](img/threat_monitoring_policy_alert_list_v13_11.png)
Clicking an alert's name takes the user to the [alert details page](../../../operations/incident_management/alerts.md#alert-details-page). Clicking an alert's name takes the user to the [alert details page](../../../operations/incident_management/alerts.md#alert-details-page).
......
...@@ -66,6 +66,10 @@ The following resources are migrated to the target instance: ...@@ -66,6 +66,10 @@ The following resources are migrated to the target instance:
- due date - due date
- created at - created at
- updated at - updated at
- Badges ([Introduced in 13.11](https://gitlab.com/gitlab-org/gitlab/-/issues/292431))
- name
- link URL
- image URL
Any other items are **not** migrated. Any other items are **not** migrated.
......
...@@ -653,3 +653,15 @@ The group's new subgroups have push rules set for them based on either: ...@@ -653,3 +653,15 @@ The group's new subgroups have push rules set for them based on either:
- [Lock the sharing with group feature](#prevent-a-project-from-being-shared-with-groups). - [Lock the sharing with group feature](#prevent-a-project-from-being-shared-with-groups).
- [Enforce two-factor authentication (2FA)](../../security/two_factor_authentication.md#enforcing-2fa-for-all-users-in-a-group): Enforce 2FA - [Enforce two-factor authentication (2FA)](../../security/two_factor_authentication.md#enforcing-2fa-for-all-users-in-a-group): Enforce 2FA
for all group members. for all group members.
## Troubleshooting
### Verify if access is blocked by IP restriction
If a user sees a 404 when they would normally expect access, and the problem is limited to a specific group, search the `auth.log` rails log for one or more of the following:
- `json.message`: `'Attempting to access IP restricted group'`
- `json.allowed`: `false`
In viewing the log entries, compare the `remote.ip` with the list of
[allowed IPs](#restrict-group-access-by-ip-address) for the group.
...@@ -96,7 +96,9 @@ Please note that the certificate [fingerprint algorithm](../../../integration/sa ...@@ -96,7 +96,9 @@ Please note that the certificate [fingerprint algorithm](../../../integration/sa
- [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/292811) in GitLab 13.8, with an updated timeout experience. - [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/292811) in GitLab 13.8, with an updated timeout experience.
- [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/211962) in GitLab 13.8 with allowing group owners to not go through SSO. - [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/211962) in GitLab 13.8 with allowing group owners to not go through SSO.
With this option enabled, users must go through your group's GitLab single sign-on URL. They may also be added via SCIM, if configured. Users can't be added manually, and may only access project/group resources via the UI by signing in through the SSO URL. With this option enabled, users must go through your group's GitLab single sign-on URL if they wish to access group resources through the UI. Users can't be manually added as members.
SSO enforcement does not affect sign in or access to any resources outside of the group. Users can view which groups and projects they are a member of without SSO sign in.
However, users are not prompted to sign in through SSO on each visit. GitLab checks whether a user However, users are not prompted to sign in through SSO on each visit. GitLab checks whether a user
has authenticated through SSO. If it's been more than 1 day since the last sign-in, GitLab has authenticated through SSO. If it's been more than 1 day since the last sign-in, GitLab
......
<script> <script>
import { GlLink, GlTable } from '@gitlab/ui'; import { GlAlert, GlLink, GlTable } from '@gitlab/ui';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import FeatureStatus from './feature_status.vue'; import FeatureStatus from './feature_status.vue';
import ManageFeature from './manage_feature.vue'; import ManageFeature from './manage_feature.vue';
...@@ -9,6 +9,7 @@ const thClass = `gl-text-gray-900 gl-bg-transparent! ${borderClasses}`; ...@@ -9,6 +9,7 @@ const thClass = `gl-text-gray-900 gl-bg-transparent! ${borderClasses}`;
export default { export default {
components: { components: {
GlAlert,
GlLink, GlLink,
GlTable, GlTable,
FeatureStatus, FeatureStatus,
...@@ -35,12 +36,20 @@ export default { ...@@ -35,12 +36,20 @@ export default {
default: '', default: '',
}, },
}, },
data() {
return {
errorMessage: '',
};
},
methods: { methods: {
getFeatureDocumentationLinkLabel(item) { getFeatureDocumentationLinkLabel(item) {
return sprintf(s__('SecurityConfiguration|Feature documentation for %{featureName}'), { return sprintf(this.$options.i18n.docsLinkLabel, {
featureName: item.name, featureName: item.name,
}); });
}, },
onError(value) {
this.errorMessage = value;
},
}, },
fields: [ fields: [
{ {
...@@ -59,42 +68,51 @@ export default { ...@@ -59,42 +68,51 @@ export default {
thClass, thClass,
}, },
], ],
i18n: {
docsLinkLabel: s__('SecurityConfiguration|Feature documentation for %{featureName}'),
docsLinkText: s__('SecurityConfiguration|More information'),
},
}; };
</script> </script>
<template> <template>
<gl-table <div>
:items="features" <gl-alert v-if="errorMessage" variant="danger" :dismissible="false">
:fields="$options.fields" {{ errorMessage }}
stacked="md" </gl-alert>
:tbody-tr-attr="{ 'data-testid': 'security-scanner-row' }" <gl-table
> :items="features"
<template #cell(description)="{ item }"> :fields="$options.fields"
<div class="gl-text-gray-900">{{ item.name }}</div> stacked="md"
<div> :tbody-tr-attr="{ 'data-testid': 'security-scanner-row' }"
{{ item.description }} >
<gl-link <template #cell(description)="{ item }">
target="_blank" <div class="gl-text-gray-900">{{ item.name }}</div>
:href="item.helpPath" <div>
:aria-label="getFeatureDocumentationLinkLabel(item)" {{ item.description }}
> <gl-link
{{ s__('SecurityConfiguration|More information') }} target="_blank"
</gl-link> :href="item.helpPath"
</div> :aria-label="getFeatureDocumentationLinkLabel(item)"
</template> >
{{ $options.i18n.docsLinkText }}
</gl-link>
</div>
</template>
<template #cell(status)="{ item }"> <template #cell(status)="{ item }">
<feature-status <feature-status
:feature="item" :feature="item"
:gitlab-ci-present="gitlabCiPresent" :gitlab-ci-present="gitlabCiPresent"
:gitlab-ci-history-path="gitlabCiHistoryPath" :gitlab-ci-history-path="gitlabCiHistoryPath"
:auto-devops-enabled="autoDevopsEnabled" :auto-devops-enabled="autoDevopsEnabled"
:data-qa-selector="`${item.type}_status`" :data-qa-selector="`${item.type}_status`"
/> />
</template> </template>
<template #cell(manage)="{ item }"> <template #cell(manage)="{ item }">
<manage-feature :feature="item" /> <manage-feature :feature="item" @error="onError" />
</template> </template>
</gl-table> </gl-table>
</div>
</template> </template>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { REPORT_TYPE_DEPENDENCY_SCANNING } from '~/vue_shared/security_reports/constants';
import configureDependencyScanningMutation from '../graphql/configure_dependency_scanning.mutation.graphql';
export const SMALL = 'SMALL'; export const SMALL = 'SMALL';
export const MEDIUM = 'MEDIUM'; export const MEDIUM = 'MEDIUM';
...@@ -15,3 +17,10 @@ export const SCHEMA_TO_PROP_SIZE_MAP = { ...@@ -15,3 +17,10 @@ export const SCHEMA_TO_PROP_SIZE_MAP = {
export const CUSTOM_VALUE_MESSAGE = s__( export const CUSTOM_VALUE_MESSAGE = s__(
"SecurityConfiguration|Using custom settings. You won't receive automatic updates on this variable. %{anchorStart}Restore to default%{anchorEnd}", "SecurityConfiguration|Using custom settings. You won't receive automatic updates on this variable. %{anchorStart}Restore to default%{anchorEnd}",
); );
export const featureToMutationMap = {
[REPORT_TYPE_DEPENDENCY_SCANNING]: {
type: 'configureDependencyScanning',
mutation: configureDependencyScanningMutation,
},
};
<script> <script>
import { propsUnion } from '~/vue_shared/components/lib/utils/props_utils'; import { propsUnion } from '~/vue_shared/components/lib/utils/props_utils';
import { REPORT_TYPE_DAST_PROFILES } from '~/vue_shared/security_reports/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_DEPENDENCY_SCANNING,
} from '~/vue_shared/security_reports/constants';
import ManageDastProfiles from './manage_dast_profiles.vue'; import ManageDastProfiles from './manage_dast_profiles.vue';
import ManageGeneric from './manage_generic.vue'; import ManageGeneric from './manage_generic.vue';
import ManageViaMr from './manage_via_mr.vue';
const scannerComponentMap = { const scannerComponentMap = {
[REPORT_TYPE_DAST_PROFILES]: ManageDastProfiles, [REPORT_TYPE_DAST_PROFILES]: ManageDastProfiles,
[REPORT_TYPE_DEPENDENCY_SCANNING]: ManageViaMr,
}; };
export default { export default {
mixins: [glFeatureFlagMixin()],
props: propsUnion([ManageGeneric, ...Object.values(scannerComponentMap)]), props: propsUnion([ManageGeneric, ...Object.values(scannerComponentMap)]),
computed: { computed: {
filteredScannerComponentMap() {
const scannerComponentMapCopy = { ...scannerComponentMap };
if (!this.glFeatures.secDependencyScanningUiEnable) {
delete scannerComponentMapCopy[REPORT_TYPE_DEPENDENCY_SCANNING];
}
return scannerComponentMapCopy;
},
manageComponent() { manageComponent() {
return scannerComponentMap[this.feature.type] ?? ManageGeneric; return this.filteredScannerComponentMap[this.feature.type] ?? ManageGeneric;
}, },
}, },
}; };
</script> </script>
<template> <template>
<component :is="manageComponent" v-bind="$props" /> <component :is="manageComponent" v-bind="$props" @error="$emit('error', $event)" />
</template> </template>
<script>
import { GlButton } from '@gitlab/ui';
import { redirectTo } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale';
import apolloProvider from '../graphql/provider';
import { featureToMutationMap } from './constants';
export default {
apolloProvider,
components: {
GlButton,
},
inject: {
projectPath: {
from: 'projectPath',
default: '',
},
},
props: {
feature: {
type: Object,
required: true,
},
},
data() {
return {
isLoading: false,
};
},
computed: {
featureSettings() {
return featureToMutationMap[this.feature.type];
},
},
methods: {
async mutate() {
this.isLoading = true;
try {
const { data } = await this.$apollo.mutate({
mutation: this.featureSettings.mutation,
variables: {
fullPath: this.projectPath,
},
});
const { errors, successPath } = data[this.featureSettings.type];
if (errors.length > 0) {
throw new Error(errors[0]);
}
if (!successPath) {
throw new Error(
sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }),
);
}
redirectTo(successPath);
} catch (e) {
this.$emit('error', e.message);
this.isLoading = false;
}
},
},
i18n: {
buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'),
noSuccessPathError: s__(
'SecurityConfiguration|%{featureName} merge request creation mutation failed',
),
},
};
</script>
<template>
<gl-button
v-if="!feature.configured"
:loading="isLoading"
variant="success"
category="secondary"
@click="mutate"
>{{ $options.i18n.buttonLabel }}</gl-button
>
</template>
mutation configureDependencyScanning($fullPath: ID!) {
configureDependencyScanning(fullPath: $fullPath) {
successPath
errors
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export default new VueApollo({
defaultClient: createDefaultClient(),
});
<script> <script>
import { GlFormCheckbox, GlFormGroup, GlSearchBoxByType } from '@gitlab/ui'; import {
import { s__ } from '~/locale'; GlDropdown,
import { DEBOUNCE, DEFAULT_FILTERS } from './constants'; GlDropdownDivider,
GlDropdownItem,
GlFormGroup,
GlIcon,
GlSearchBoxByType,
GlTruncate,
} from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import { ALL, DEBOUNCE, STATUSES } from './constants';
export default { export default {
ALL,
DEBOUNCE, DEBOUNCE,
DEFAULT_DISMISSED_FILTER: true, DEFAULT_DISMISSED_FILTER: true,
components: { GlFormCheckbox, GlFormGroup, GlSearchBoxByType }, components: {
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlFormGroup,
GlIcon,
GlSearchBoxByType,
GlTruncate,
},
props: { props: {
filters: { filters: {
type: Object, type: Object,
required: false, required: false,
default: () => {}, default: () => ({}),
}, },
}, },
data() { data() {
...@@ -20,46 +37,107 @@ export default { ...@@ -20,46 +37,107 @@ export default {
}; };
}, },
i18n: { i18n: {
STATUSES,
HIDE_DISMISSED_TITLE: s__('ThreatMonitoring|Hide dismissed alerts'), HIDE_DISMISSED_TITLE: s__('ThreatMonitoring|Hide dismissed alerts'),
POLICY_NAME_FILTER_PLACEHOLDER: s__('NetworkPolicy|Search by policy name'), POLICY_NAME_FILTER_PLACEHOLDER: s__('NetworkPolicy|Search by policy name'),
POLICY_NAME_FILTER_TITLE: s__('NetworkPolicy|Policy'), POLICY_NAME_FILTER_TITLE: s__('NetworkPolicy|Policy'),
POLICY_STATUS_FILTER_TITLE: s__('NetworkPolicy|Status'),
},
computed: {
extraOptionCount() {
const numOfStatuses = this.filters.statuses?.length || 0;
return numOfStatuses > 0 ? numOfStatuses - 1 : 0;
},
firstSelectedOption() {
const firstOption = this.filters.statuses?.length ? this.filters.statuses[0] : undefined;
return this.$options.i18n.STATUSES[firstOption] || this.$options.ALL.value;
},
extraOptionText() {
return sprintf(__('+%{extra} more'), { extra: this.extraOptionCount });
},
}, },
methods: { methods: {
changeDismissedFilter(filtered) { handleFilterChange(newFilters) {
const newFilters = filtered ? DEFAULT_FILTERS : { statuses: [] }; this.$emit('filter-change', { ...this.filters, ...newFilters });
this.handleFilterChange(newFilters);
}, },
handleSearch(searchTerm) { handleNameFilter(searchTerm) {
const newFilters = { searchTerm }; const newFilters = { searchTerm };
this.handleFilterChange(newFilters); this.handleFilterChange(newFilters);
}, },
handleFilterChange(newFilters) { handleStatusFilter(status) {
this.$emit('filter-change', { ...this.filters, ...newFilters }); let newFilters;
if (status === this.$options.ALL.key) {
newFilters = { statuses: [] };
} else {
newFilters = this.isChecked(status)
? { statuses: [...this.filters.statuses.filter((s) => s !== status)] }
: { statuses: [...this.filters.statuses, status] };
}
// If all statuses are selected, select the 'All' option
if (newFilters.statuses.length === Object.entries(STATUSES).length) {
newFilters = { statuses: [] };
}
this.handleFilterChange(newFilters);
},
isChecked(status) {
if (status === this.$options.ALL.key) {
return !this.filters.statuses?.length;
}
return this.filters.statuses?.includes(status);
}, },
}, },
}; };
</script> </script>
<template> <template>
<div <div class="gl-p-4 gl-bg-gray-10 gl-display-flex gl-align-items-center">
class="gl-p-4 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between gl-align-items-center" <gl-form-group :label="$options.i18n.POLICY_NAME_FILTER_TITLE" label-size="sm" class="gl-mb-0">
>
<div>
<h5 class="gl-mt-0">{{ $options.i18n.POLICY_NAME_FILTER_TITLE }}</h5>
<gl-search-box-by-type <gl-search-box-by-type
:debounce="$options.DEBOUNCE" :debounce="$options.DEBOUNCE"
:placeholder="$options.i18n.POLICY_NAME_FILTER_PLACEHOLDER" :placeholder="$options.i18n.POLICY_NAME_FILTER_PLACEHOLDER"
@input="handleSearch" @input="handleNameFilter"
/> />
</div> </gl-form-group>
<gl-form-group label-size="sm" class="gl-mb-0"> <gl-form-group
<gl-form-checkbox :label="$options.i18n.POLICY_STATUS_FILTER_TITLE"
class="gl-mt-3" label-size="sm"
:checked="$options.DEFAULT_DISMISSED_FILTER" class="gl-mb-0 col-sm-6 col-md-4 col-lg-2"
@change="changeDismissedFilter" data-testid="policy-alert-status-filter"
> >
{{ $options.i18n.HIDE_DISMISSED_TITLE }} <gl-dropdown toggle-class="gl-inset-border-1-gray-400!" class="gl-w-full">
</gl-form-checkbox> <template #button-content>
<gl-truncate :text="firstSelectedOption" class="gl-min-w-0 gl-mr-2" />
<span v-if="extraOptionCount > 0" class="gl-mr-2">
{{ extraOptionText }}
</span>
<gl-icon name="chevron-down" class="gl-flex-shrink-0 gl-ml-auto" />
</template>
<gl-dropdown-item
key="All"
data-testid="ALL"
:is-checked="isChecked($options.ALL.key)"
is-check-item
@click="handleStatusFilter($options.ALL.key)"
>
{{ $options.ALL.value }}
</gl-dropdown-item>
<gl-dropdown-divider />
<template v-for="[status, translated] in Object.entries($options.i18n.STATUSES)">
<gl-dropdown-item
:key="status"
:data-testid="status"
:is-checked="isChecked(status)"
is-check-item
@click="handleStatusFilter(status)"
>
{{ translated }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</gl-form-group> </gl-form-group>
</div> </div>
</template> </template>
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
export const MESSAGES = { export const MESSAGES = {
CONFIGURE: s__( CONFIGURE: s__(
...@@ -58,3 +58,5 @@ export const DEFAULT_FILTERS = { statuses: ['TRIGGERED', 'ACKNOWLEDGED'] }; ...@@ -58,3 +58,5 @@ export const DEFAULT_FILTERS = { statuses: ['TRIGGERED', 'ACKNOWLEDGED'] };
export const DOMAIN = 'threat_monitoring'; export const DOMAIN = 'threat_monitoring';
export const DEBOUNCE = 250; export const DEBOUNCE = 250;
export const ALL = { key: 'ALL', value: __('All') };
...@@ -15,6 +15,7 @@ module EE ...@@ -15,6 +15,7 @@ module EE
before_action only: [:show] do before_action only: [:show] do
push_frontend_feature_flag(:security_auto_fix, project, default_enabled: false) push_frontend_feature_flag(:security_auto_fix, project, default_enabled: false)
push_frontend_feature_flag(:api_fuzzing_configuration_ui, project, default_enabled: :yaml) push_frontend_feature_flag(:api_fuzzing_configuration_ui, project, default_enabled: :yaml)
push_frontend_feature_flag(:sec_dependency_scanning_ui_enable, project, default_enabled: :yaml)
end end
before_action only: [:auto_fix] do before_action only: [:auto_fix] do
......
...@@ -32,15 +32,20 @@ module EE ...@@ -32,15 +32,20 @@ module EE
field :vulnerabilities_count_by_day, field :vulnerabilities_count_by_day,
::Types::VulnerabilitiesCountByDayType.connection_type, ::Types::VulnerabilitiesCountByDayType.connection_type,
null: true, null: true,
description: "Number of vulnerabilities per day for the projects on the current user's instance security dashboard.", resolver: ::Resolvers::VulnerabilitiesCountPerDayResolver,
resolver: ::Resolvers::VulnerabilitiesCountPerDayResolver description: <<~DESC
Number of vulnerabilities per day for the projects on the current user's instance security dashboard.
DESC
field :vulnerabilities_count_by_day_and_severity, field :vulnerabilities_count_by_day_and_severity,
::Types::VulnerabilitiesCountByDayAndSeverityType.connection_type, ::Types::VulnerabilitiesCountByDayAndSeverityType.connection_type,
null: true, null: true,
description: "Number of vulnerabilities per severity level, per day, for the projects on the current user's instance security dashboard.",
resolver: ::Resolvers::VulnerabilitiesHistoryResolver, resolver: ::Resolvers::VulnerabilitiesHistoryResolver,
deprecated: { reason: :discouraged, replacement: 'Query.vulnerabilitiesCountByDay', milestone: '13.3' } deprecated: { reason: :discouraged, replacement: 'Query.vulnerabilitiesCountByDay', milestone: '13.3' },
description: <<~DESC
Number of vulnerabilities per severity level, per day, for the projects on the
current user's instance security dashboard.
DESC
field :geo_node, ::Types::Geo::GeoNodeType, field :geo_node, ::Types::Geo::GeoNodeType,
null: true, null: true,
......
...@@ -25,10 +25,14 @@ module EE ...@@ -25,10 +25,14 @@ module EE
controllers = %w(issues_analytics#show) controllers = %w(issues_analytics#show)
if @group&.feature_available?(:iterations) if @group&.feature_available?(:iterations)
controllers = %w(iterations#index) controllers = iterations_sub_menu_controllers
end end
super.concat(controllers) super.concat(controllers)
end end
def iterations_sub_menu_controllers
['iterations#index', 'iterations#show']
end
end end
end end
- if group_sidebar_link?(:iterations) - if group_sidebar_link?(:iterations)
= nav_link(path: 'iterations#index') do = nav_link(path: iterations_sub_menu_controllers) do
= link_to group_iterations_path(@group), data: { qa_selector: 'group_iterations_link' } do = link_to group_iterations_path(@group), data: { qa_selector: 'group_iterations_link' } do
%span %span
= _('Iterations') = _('Iterations')
---
title: Create status filter on policy alerts
merge_request: 57538
author:
type: changed
---
title: Expand left sidebar when viewing group iteration report
merge_request: 56358
author:
type: fixed
---
name: sec_dependency_scanning_ui_enable
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57496
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/326005
milestone: '13.11'
type: development
group: group::composition analysis
default_enabled: false
...@@ -33,6 +33,12 @@ RSpec.describe 'User views iteration' do ...@@ -33,6 +33,12 @@ RSpec.describe 'User views iteration' do
end end
it 'shows iteration info' do it 'shows iteration info' do
aggregate_failures 'expect Iterations highlighted on left sidebar' do
page.within '.qa-group-sidebar' do
expect(page).to have_css('li.active > a', text: 'Iterations')
end
end
aggregate_failures 'expect title, description, and dates' do aggregate_failures 'expect title, description, and dates' do
expect(page).to have_content(iteration.title) expect(page).to have_content(iteration.title)
expect(page).to have_content(iteration.description) expect(page).to have_content(iteration.description)
......
...@@ -14,7 +14,6 @@ RSpec.describe 'Group or Project invitations' do ...@@ -14,7 +14,6 @@ RSpec.describe 'Group or Project invitations' do
allow(::Gitlab).to receive(:dev_env_or_com?).and_return(dev_env_or_com) allow(::Gitlab).to receive(:dev_env_or_com?).and_return(dev_env_or_com)
visit invite_path(group_invite.raw_invite_token) visit invite_path(group_invite.raw_invite_token)
click_link 'Register now'
end end
def fill_in_sign_up_form(user) def fill_in_sign_up_form(user)
......
const buildConfigureDependencyScanningMock = ({
successPath = 'testSuccessPath',
errors = [],
} = {}) => ({
data: {
configureDependencyScanning: {
successPath,
errors,
__typename: 'ConfigureDependencyScanningPayload',
},
},
});
export const configureDependencyScanningSuccess = buildConfigureDependencyScanningMock();
export const configureDependencyScanningNoSuccessPath = buildConfigureDependencyScanningMock({
successPath: '',
});
export const configureDependencyScanningError = buildConfigureDependencyScanningMock({
errors: ['foo'],
});
import { GlLink } from '@gitlab/ui'; import { GlAlert, GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ConfigurationTable from 'ee/security_configuration/components/configuration_table.vue'; import ConfigurationTable from 'ee/security_configuration/components/configuration_table.vue';
import FeatureStatus from 'ee/security_configuration/components/feature_status.vue'; import FeatureStatus from 'ee/security_configuration/components/feature_status.vue';
...@@ -41,6 +41,7 @@ describe('ConfigurationTable component', () => { ...@@ -41,6 +41,7 @@ describe('ConfigurationTable component', () => {
}); });
}; };
const getTable = () => wrapper.find('table');
const getRows = () => wrapper.findAll('tbody tr'); const getRows = () => wrapper.findAll('tbody tr');
const getRowCells = (row) => { const getRowCells = (row) => {
const [description, status, manage] = row.findAll('td').wrappers; const [description, status, manage] = row.findAll('td').wrappers;
...@@ -53,8 +54,7 @@ describe('ConfigurationTable component', () => { ...@@ -53,8 +54,7 @@ describe('ConfigurationTable component', () => {
it.each(mockFeatures)('renders the feature %p correctly', (feature) => { it.each(mockFeatures)('renders the feature %p correctly', (feature) => {
createComponent({ features: [feature] }); createComponent({ features: [feature] });
expect(getTable().classes('b-table-stacked-md')).toBe(true);
expect(wrapper.classes('b-table-stacked-md')).toBeTruthy();
const rows = getRows(); const rows = getRows();
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);
...@@ -70,4 +70,16 @@ describe('ConfigurationTable component', () => { ...@@ -70,4 +70,16 @@ describe('ConfigurationTable component', () => {
expect(manage.find(ManageFeature).props()).toEqual({ feature }); expect(manage.find(ManageFeature).props()).toEqual({ feature });
expect(description.find(GlLink).attributes('href')).toBe(feature.helpPath); expect(description.find(GlLink).attributes('href')).toBe(feature.helpPath);
}); });
it('catches errors and displays them in an alert', async () => {
const error = 'error message';
createComponent({ features: mockFeatures });
const firstRow = getRows().at(0);
await firstRow.findComponent(ManageFeature).vm.$emit('error', error);
const alert = wrapper.findComponent(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(error);
});
}); });
...@@ -2,7 +2,11 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,7 +2,11 @@ import { shallowMount } from '@vue/test-utils';
import ManageDastProfiles from 'ee/security_configuration/components/manage_dast_profiles.vue'; import ManageDastProfiles from 'ee/security_configuration/components/manage_dast_profiles.vue';
import ManageFeature from 'ee/security_configuration/components/manage_feature.vue'; import ManageFeature from 'ee/security_configuration/components/manage_feature.vue';
import ManageGeneric from 'ee/security_configuration/components/manage_generic.vue'; import ManageGeneric from 'ee/security_configuration/components/manage_generic.vue';
import { REPORT_TYPE_DAST_PROFILES } from '~/vue_shared/security_reports/constants'; import ManageViaMr from 'ee/security_configuration/components/manage_via_mr.vue';
import {
REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_DEPENDENCY_SCANNING,
} from '~/vue_shared/security_reports/constants';
import { generateFeatures } from './helpers'; import { generateFeatures } from './helpers';
const attrs = { const attrs = {
...@@ -13,7 +17,14 @@ describe('ManageFeature component', () => { ...@@ -13,7 +17,14 @@ describe('ManageFeature component', () => {
let wrapper; let wrapper;
const createComponent = (options) => { const createComponent = (options) => {
wrapper = shallowMount(ManageFeature, options); wrapper = shallowMount(ManageFeature, {
provide: {
glFeatures: {
secDependencyScanningUiEnable: true,
},
},
...options,
});
}; };
afterEach(() => { afterEach(() => {
...@@ -29,12 +40,20 @@ describe('ManageFeature component', () => { ...@@ -29,12 +40,20 @@ describe('ManageFeature component', () => {
it('passes through attributes to the expected component', () => { it('passes through attributes to the expected component', () => {
expect(wrapper.attributes()).toMatchObject(attrs); expect(wrapper.attributes()).toMatchObject(attrs);
}); });
it('re-emits caught errors', () => {
const component = wrapper.findComponent(ManageGeneric);
component.vm.$emit('error', 'testerror');
expect(wrapper.emitted('error')).toEqual([['testerror']]);
});
}); });
describe.each` describe.each`
type | expectedComponent type | expectedComponent
${REPORT_TYPE_DAST_PROFILES} | ${ManageDastProfiles} ${REPORT_TYPE_DAST_PROFILES} | ${ManageDastProfiles}
${'foo'} | ${ManageGeneric} ${REPORT_TYPE_DEPENDENCY_SCANNING} | ${ManageViaMr}
${'foo'} | ${ManageGeneric}
`('given a $type feature', ({ type, expectedComponent }) => { `('given a $type feature', ({ type, expectedComponent }) => {
let feature; let feature;
let component; let component;
...@@ -43,7 +62,6 @@ describe('ManageFeature component', () => { ...@@ -43,7 +62,6 @@ describe('ManageFeature component', () => {
[feature] = generateFeatures(1, { type }); [feature] = generateFeatures(1, { type });
createComponent({ propsData: { feature } }); createComponent({ propsData: { feature } });
component = wrapper.findComponent(expectedComponent); component = wrapper.findComponent(expectedComponent);
}); });
...@@ -55,4 +73,21 @@ describe('ManageFeature component', () => { ...@@ -55,4 +73,21 @@ describe('ManageFeature component', () => {
expect(component.props()).toEqual({ feature }); expect(component.props()).toEqual({ feature });
}); });
}); });
it.each`
type | featureFlag
${REPORT_TYPE_DEPENDENCY_SCANNING} | ${'secDependencyScanningUiEnable'}
`('renders generic component for $type if $featureFlag is disabled', ({ type, featureFlag }) => {
const [feature] = generateFeatures(1, { type });
createComponent({
propsData: { feature },
provide: {
glFeatures: {
[featureFlag]: false,
},
},
});
expect(wrapper.findComponent(ManageGeneric).exists()).toBe(true);
});
}); });
import { GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import ManageViaMr from 'ee/security_configuration/components/manage_via_mr.vue';
import configureDependencyScanningMutation from 'ee/security_configuration/graphql/configure_dependency_scanning.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { redirectTo } from '~/lib/utils/url_utility';
import { REPORT_TYPE_DEPENDENCY_SCANNING } from '~/vue_shared/security_reports/constants';
import {
configureDependencyScanningSuccess,
configureDependencyScanningNoSuccessPath,
configureDependencyScanningError,
} from './apollo_mocks';
jest.mock('~/lib/utils/url_utility');
Vue.use(VueApollo);
describe('ManageViaMr component', () => {
let wrapper;
const findButton = () => wrapper.findComponent(GlButton);
const successHandler = async () => configureDependencyScanningSuccess;
const noSuccessPathHandler = async () => configureDependencyScanningNoSuccessPath;
const errorHandler = async () => configureDependencyScanningError;
const pendingHandler = () => new Promise(() => {});
function createMockApolloProvider(handler) {
const requestHandlers = [[configureDependencyScanningMutation, handler]];
return createMockApollo(requestHandlers);
}
function createComponent({ mockApollo, isFeatureConfigured = false } = {}) {
wrapper = extendedWrapper(
mount(ManageViaMr, {
apolloProvider: mockApollo,
propsData: {
feature: {
name: 'Dependency Scanning',
configured: isFeatureConfigured,
type: REPORT_TYPE_DEPENDENCY_SCANNING,
},
},
}),
);
}
afterEach(() => {
wrapper.destroy();
});
describe('when feature is configured', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(successHandler);
createComponent({ mockApollo, isFeatureConfigured: true });
});
it('it does not render a button', () => {
expect(findButton().exists()).toBe(false);
});
});
describe('when feature is not configured', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(successHandler);
createComponent({ mockApollo, isFeatureConfigured: false });
});
it('it does render a button', () => {
expect(findButton().exists()).toBe(true);
});
});
describe('given a pending response', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(pendingHandler);
createComponent({ mockApollo });
});
it('renders spinner correctly', async () => {
const button = findButton();
expect(button.props('loading')).toBe(false);
await button.trigger('click');
expect(button.props('loading')).toBe(true);
});
});
describe('given a successful response', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(successHandler);
createComponent({ mockApollo });
});
it('should call redirect helper with correct value', async () => {
await wrapper.trigger('click');
await waitForPromises();
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith('testSuccessPath');
// This is done for UX reasons. If the loading prop is set to false
// on success, then there's a period where the button is clickable
// again. Instead, we want the button to display a loading indicator
// for the remainder of the lifetime of the page (i.e., until the
// browser can start painting the new page it's been redirected to).
expect(findButton().props().loading).toBe(true);
});
});
describe.each`
handler | message
${noSuccessPathHandler} | ${'Dependency Scanning merge request creation mutation failed'}
${errorHandler} | ${'foo'}
`('given an error response', ({ handler, message }) => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(handler);
createComponent({ mockApollo });
});
it('should catch and emit error', async () => {
await wrapper.trigger('click');
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[message]]);
expect(findButton().props('loading')).toBe(false);
});
});
});
import { GlFormCheckbox, GlSearchBoxByType } from '@gitlab/ui'; import { GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import AlertFilters from 'ee/threat_monitoring/components/alerts/alert_filters.vue'; import AlertFilters from 'ee/threat_monitoring/components/alerts/alert_filters.vue';
import { DEFAULT_FILTERS } from 'ee/threat_monitoring/components/alerts/constants'; import { ALL, DEFAULT_FILTERS, STATUSES } from 'ee/threat_monitoring/components/alerts/constants';
import { trimText } from 'helpers/text_helper';
describe('AlertFilters component', () => { describe('AlertFilters component', () => {
let wrapper; let wrapper;
const findGlFormCheckbox = () => wrapper.find(GlFormCheckbox); const findDropdownItemAtIndex = (index) => wrapper.findAll(GlDropdownItem).at(index);
const findGlSearch = () => wrapper.find(GlSearchBoxByType); const clickDropdownItemAtIndex = (index) => findDropdownItemAtIndex(index).vm.$emit('click');
const findSearch = () => wrapper.findComponent(GlSearchBoxByType);
const findDropdownMessage = () =>
wrapper.find('[data-testid="policy-alert-status-filter"] .dropdown button').text();
const createWrapper = (filters = DEFAULT_FILTERS) => { const createWrapper = ({ filters = DEFAULT_FILTERS, method = shallowMount } = {}) => {
wrapper = shallowMount(AlertFilters, { propsData: { filters } }); wrapper = method(AlertFilters, { propsData: { filters } });
}; };
afterEach(() => { afterEach(() => {
...@@ -20,19 +24,19 @@ describe('AlertFilters component', () => { ...@@ -20,19 +24,19 @@ describe('AlertFilters component', () => {
describe('Policy Name Filter', () => { describe('Policy Name Filter', () => {
beforeEach(() => { beforeEach(() => {
createWrapper(); createWrapper({});
}); });
describe('default state', () => { describe('default state', () => {
it('shows policy name search box', () => { it('shows policy name search box', () => {
const search = findGlSearch(); const search = findSearch();
expect(search.exists()).toBe(true); expect(search.exists()).toBe(true);
expect(search.attributes('value')).toBe(''); expect(search.attributes('value')).toBe('');
}); });
it('does emit an event with a user-defined string', async () => { it('does emit an event with a user-defined string', async () => {
const searchTerm = 'abc'; const searchTerm = 'abc';
const search = findGlSearch(); const search = findSearch();
search.vm.$emit('input', searchTerm); search.vm.$emit('input', searchTerm);
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.emitted('filter-change')).toStrictEqual([ expect(wrapper.emitted('filter-change')).toStrictEqual([
...@@ -42,32 +46,58 @@ describe('AlertFilters component', () => { ...@@ -42,32 +46,58 @@ describe('AlertFilters component', () => {
}); });
}); });
describe('Hide Dismissed Filter', () => { describe('Status Filter', () => {
describe('default state', () => { it('Displays the "All" status if no statuses are selected', () => {
it('"hide dismissed checkbox" is checked', () => { createWrapper({ method: mount, filters: { statuses: [] } });
createWrapper(); expect(findDropdownMessage()).toBe(ALL.value);
const checkbox = findGlFormCheckbox();
expect(checkbox.exists()).toBe(true);
expect(checkbox.attributes('checked')).toBeTruthy();
});
}); });
describe('dismissed alerts filter', () => { it('Displays the status if only one status is selected', () => {
it('emits an event with no filters on filter deselect', async () => { const status = 'TRIGGERED';
createWrapper(); const translated = STATUSES[status];
const checkbox = findGlFormCheckbox(); createWrapper({ method: mount, filters: { statuses: [status] } });
checkbox.vm.$emit('change', false); expect(findDropdownMessage()).toBe(translated);
await wrapper.vm.$nextTick(); });
expect(wrapper.emitted('filter-change')).toStrictEqual([[{ statuses: [] }]]);
});
it('emits an event with the default filters on filter select', async () => { it('Displays the additional text if more than one status is selected', () => {
createWrapper({}); const status = 'TRIGGERED';
const checkbox = findGlFormCheckbox(); const translated = STATUSES[status];
checkbox.vm.$emit('change', true); createWrapper({ method: mount });
await wrapper.vm.$nextTick(); expect(trimText(findDropdownMessage())).toBe(`${translated} +1 more`);
expect(wrapper.emitted('filter-change')).toEqual([[DEFAULT_FILTERS]]); });
it('Emits an event with the new filters on deselect', async () => {
createWrapper({});
clickDropdownItemAtIndex(2);
expect(wrapper.emitted('filter-change')).toHaveLength(1);
expect(wrapper.emitted('filter-change')[0][0]).toStrictEqual({ statuses: ['TRIGGERED'] });
});
it('Emits an event with the new filters on a select', () => {
createWrapper({});
clickDropdownItemAtIndex(4);
expect(wrapper.emitted('filter-change')).toHaveLength(1);
expect(wrapper.emitted('filter-change')[0][0]).toStrictEqual({
statuses: ['TRIGGERED', 'ACKNOWLEDGED', 'IGNORED'],
}); });
}); });
it('Emits an event with no filters on a select of all the filters', () => {
const MOST_STATUSES = [...Object.keys(STATUSES)].slice(1);
createWrapper({ filters: { statuses: MOST_STATUSES } });
clickDropdownItemAtIndex(1);
expect(wrapper.emitted('filter-change')).toHaveLength(1);
expect(wrapper.emitted('filter-change')[0][0]).toStrictEqual({ statuses: [] });
});
it('Checks "All" filter if no statuses are selected', () => {
createWrapper({ filters: { statuses: [] } });
expect(findDropdownItemAtIndex(0).props('isChecked')).toBe(true);
});
it('Unchecks "All" filter if a status is selected', () => {
createWrapper({});
expect(findDropdownItemAtIndex(0).props('isChecked')).toBe(false);
});
}); });
}); });
...@@ -23,7 +23,7 @@ module BulkImports ...@@ -23,7 +23,7 @@ module BulkImports
resource_url(resource), resource_url(resource),
headers: request_headers, headers: request_headers,
follow_redirects: false, follow_redirects: false,
query: query.merge(request_query) query: query.reverse_merge(request_query)
) )
end end
end end
......
# frozen_string_literal: true
module BulkImports
module Common
module Extractors
class RestExtractor
def initialize(options = {})
@query = options[:query]
end
def extract(context)
client = http_client(context.configuration)
params = query.to_h(context)
response = client.get(params[:resource], params[:query])
BulkImports::Pipeline::ExtractedData.new(
data: response.parsed_response,
page_info: page_info(response.headers)
)
end
private
attr_reader :query
def http_client(configuration)
@http_client ||= BulkImports::Clients::Http.new(
uri: configuration.url,
token: configuration.access_token,
per_page: 100
)
end
def page_info(headers)
next_page = headers['x-next-page']
{
'has_next_page' => next_page.present?,
'next_page' => next_page
}
end
end
end
end
end
# frozen_string_literal: true
module BulkImports
module Groups
module Pipelines
class BadgesPipeline
include Pipeline
extractor BulkImports::Common::Extractors::RestExtractor,
query: BulkImports::Groups::Rest::GetBadgesQuery
transformer Common::Transformers::ProhibitedAttributesTransformer
def transform(_, data)
return if data.blank?
{
name: data['name'],
link_url: data['link_url'],
image_url: data['image_url']
}
end
def load(context, data)
return if data.blank?
context.group.badges.create!(data)
end
end
end
end
end
# frozen_string_literal: true
module BulkImports
module Groups
module Rest
module GetBadgesQuery
extend self
def to_h(context)
encoded_full_path = ERB::Util.url_encode(context.entity.source_full_path)
{
resource: ['groups', encoded_full_path, 'badges'].join('/'),
query: {
page: context.tracker.next_page
}
}
end
end
end
end
end
...@@ -34,7 +34,8 @@ module BulkImports ...@@ -34,7 +34,8 @@ module BulkImports
BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline,
BulkImports::Groups::Pipelines::MembersPipeline, BulkImports::Groups::Pipelines::MembersPipeline,
BulkImports::Groups::Pipelines::LabelsPipeline, BulkImports::Groups::Pipelines::LabelsPipeline,
BulkImports::Groups::Pipelines::MilestonesPipeline BulkImports::Groups::Pipelines::MilestonesPipeline,
BulkImports::Groups::Pipelines::BadgesPipeline
] ]
end end
end end
......
# frozen_string_literal: true
require 'date'
require 'pathname'
# https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page
namespace :gitlab do
namespace :docs do
desc 'GitLab | Docs | Create a doc redirect'
task :redirect, [:old_path, :new_path] do |_, args|
if args.old_path
old_path = args.old_path
else
puts '=> Enter the path of the OLD file:'
old_path = STDIN.gets.chomp
end
if args.new_path
new_path = args.new_path
else
puts '=> Enter the path of the NEW file:'
new_path = STDIN.gets.chomp
end
#
# If the new path is a relative URL, find the relative path between
# the old and new paths.
# The returned path is one level deeper, so remove the leading '../'.
#
unless new_path.start_with?('http')
old_pathname = Pathname.new(old_path)
new_pathname = Pathname.new(new_path)
relative_path = new_pathname.relative_path_from(old_pathname).to_s
(_, *last) = relative_path.split('/')
new_path = last.join('/')
end
#
# - If this is an external URL, move the date 1 year later.
# - If this is a relative URL, move the date 3 months later.
#
date = Time.now.utc.strftime('%Y-%m-%d')
date = new_path.start_with?('http') ? Date.parse(date) >> 12 : Date.parse(date) >> 3
puts "=> Creating new redirect from #{old_path} to #{new_path}"
File.open(old_path, 'w') do |post|
post.puts '---'
post.puts "redirect_to: '#{new_path}'"
post.puts '---'
post.puts
post.puts "This file was moved to [another location](#{new_path})."
post.puts
post.puts "<!-- This redirect file can be deleted after <#{date}>. -->"
post.puts "<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->"
end
end
end
end
...@@ -1074,6 +1074,9 @@ msgstr[1] "" ...@@ -1074,6 +1074,9 @@ msgstr[1] ""
msgid "+%{approvers} more approvers" msgid "+%{approvers} more approvers"
msgstr "" msgstr ""
msgid "+%{extra} more"
msgstr ""
msgid "+%{more_assignees_count}" msgid "+%{more_assignees_count}"
msgstr "" msgstr ""
...@@ -20546,6 +20549,9 @@ msgstr "" ...@@ -20546,6 +20549,9 @@ msgstr ""
msgid "NetworkPolicy|Search by policy name" msgid "NetworkPolicy|Search by policy name"
msgstr "" msgstr ""
msgid "NetworkPolicy|Status"
msgstr ""
msgid "Never" msgid "Never"
msgstr "" msgstr ""
...@@ -25470,7 +25476,7 @@ msgstr "" ...@@ -25470,7 +25476,7 @@ msgstr ""
msgid "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software." msgid "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software."
msgstr "" msgstr ""
msgid "Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}." msgid "Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0.0%{codeEnd}, %{codeStart}v2.1.0-pre%{codeEnd}."
msgstr "" msgstr ""
msgid "Releases documentation" msgid "Releases documentation"
...@@ -27056,6 +27062,9 @@ msgstr "" ...@@ -27056,6 +27062,9 @@ msgstr ""
msgid "SecurityApprovals|Requires approval for vulnerabilities of Critical, High, or Unknown severity. %{linkStart}Learn more.%{linkEnd}" msgid "SecurityApprovals|Requires approval for vulnerabilities of Critical, High, or Unknown severity. %{linkStart}Learn more.%{linkEnd}"
msgstr "" msgstr ""
msgid "SecurityConfiguration|%{featureName} merge request creation mutation failed"
msgstr ""
msgid "SecurityConfiguration|An error occurred while creating the merge request." msgid "SecurityConfiguration|An error occurred while creating the merge request."
msgstr "" msgstr ""
...@@ -31819,6 +31828,15 @@ msgstr "" ...@@ -31819,6 +31828,15 @@ msgstr ""
msgid "To GitLab" msgid "To GitLab"
msgstr "" msgstr ""
msgid "To accept this invitation, create an account or sign in."
msgstr ""
msgid "To accept this invitation, sign in or create an account."
msgstr ""
msgid "To accept this invitation, sign in."
msgstr ""
msgid "To access this domain create a new DNS record" msgid "To access this domain create a new DNS record"
msgstr "" msgstr ""
......
...@@ -54,6 +54,10 @@ module RuboCop ...@@ -54,6 +54,10 @@ module RuboCop
(send nil? :value ...) (send nil? :value ...)
PATTERN PATTERN
def_node_matcher :resolver_kwarg, <<~PATTERN
(... (hash <(pair (sym :resolver) $_) ...>))
PATTERN
def_node_matcher :description_kwarg, <<~PATTERN def_node_matcher :description_kwarg, <<~PATTERN
(... (hash <(pair (sym :description) $_) ...>)) (... (hash <(pair (sym :description) $_) ...>))
PATTERN PATTERN
...@@ -64,6 +68,7 @@ module RuboCop ...@@ -64,6 +68,7 @@ module RuboCop
def on_send(node) def on_send(node)
return unless graphql_describable?(node) return unless graphql_describable?(node)
return if resolver_kwarg(node) # Fields may inherit the description from their resolvers.
description = locate_description(node) description = locate_description(node)
......
...@@ -4,7 +4,7 @@ require 'spec_helper' ...@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe InvitesController do RSpec.describe InvitesController do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:member) { create(:project_member, :invited, invite_email: user.email) } let_it_be(:member, reload: true) { create(:project_member, :invited, invite_email: user.email) }
let(:raw_invite_token) { member.raw_invite_token } let(:raw_invite_token) { member.raw_invite_token }
let(:project_members) { member.source.users } let(:project_members) { member.source.users }
let(:md5_member_global_id) { Digest::MD5.hexdigest(member.to_global_id.to_s) } let(:md5_member_global_id) { Digest::MD5.hexdigest(member.to_global_id.to_s) }
...@@ -77,10 +77,83 @@ RSpec.describe InvitesController do ...@@ -77,10 +77,83 @@ RSpec.describe InvitesController do
context 'when not logged in' do context 'when not logged in' do
context 'when inviter is a member' do context 'when inviter is a member' do
it 'is redirected to a new session with invite email param' do context 'when instance allows sign up' do
request it 'indicates an account can be created in notice' do
request
expect(flash[:notice]).to include('or create an account')
end
context 'when user exists with the invited email' do
it 'is redirected to a new session with invite email param' do
request
expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email))
end
end
context 'when user exists with the invited email as secondary email' do
before do
secondary_email = create(:email, user: user, email: 'foo@example.com')
member.update!(invite_email: secondary_email.email)
end
it 'is redirected to a new session with invite email param' do
request
expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email))
end
end
context 'when user does not exist with the invited email' do
before do
member.update!(invite_email: 'bogus_email@example.com')
end
it 'indicates an account can be created in notice' do
request
expect(flash[:notice]).to include('create an account or sign in')
end
it 'is redirected to a new registration with invite email param' do
request
expect(response).to redirect_to(new_user_registration_path(invite_email: member.invite_email))
end
end
end
context 'when instance does not allow sign up' do
before do
stub_application_setting(allow_signup?: false)
end
it 'does not indicate an account can be created in notice' do
request
expect(flash[:notice]).not_to include('or create an account')
end
context 'when user exists with the invited email' do
it 'is redirected to a new session with invite email param' do
request
expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email))
end
end
context 'when user does not exist with the invited email' do
before do
member.update!(invite_email: 'bogus_email@example.com')
end
it 'is redirected to a new session with invite email param' do
request
expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email)) expect(response).to redirect_to(new_user_session_path(invite_email: member.invite_email))
end
end
end end
end end
......
...@@ -56,28 +56,6 @@ RSpec.describe Projects::RepositoriesController do ...@@ -56,28 +56,6 @@ RSpec.describe Projects::RepositoriesController do
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:") expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
end end
it 'handles legacy queries with no ref' do
get :archive, params: { namespace_id: project.namespace, project_id: project }, format: "zip"
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
end
it 'handles legacy queries with the ref specified as ref in params' do
get :archive, params: { namespace_id: project.namespace, project_id: project, ref: 'feature' }, format: 'zip'
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:ref)).to eq('feature')
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
end
it 'handles legacy queries with the ref specified as id in params' do
get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'feature' }, format: 'zip'
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:ref)).to eq('feature')
expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:")
end
it 'prioritizes the id param over the ref param when both are specified' do it 'prioritizes the id param over the ref param when both are specified' do
get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'feature', ref: 'feature_conflict' }, format: 'zip' get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'feature', ref: 'feature_conflict' }, format: 'zip'
......
...@@ -50,21 +50,23 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do ...@@ -50,21 +50,23 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end end
it 'renders sign in page with sign in notice' do it 'renders sign in page with sign in notice' do
expect(current_path).to eq(new_user_session_path) expect(current_path).to eq(new_user_registration_path)
expect(page).to have_content('To accept this invitation, sign in') expect(page).to have_content('To accept this invitation, create an account or sign in')
end end
it 'pre-fills the "Username or email" field on the sign in box with the invite_email from the invite' do it 'pre-fills the "Username or email" field on the sign in box with the invite_email from the invite' do
click_link 'Sign in'
expect(find_field('Username or email').value).to eq(group_invite.invite_email) expect(find_field('Username or email').value).to eq(group_invite.invite_email)
end end
it 'pre-fills the Email field on the sign up box with the invite_email from the invite' do it 'pre-fills the Email field on the sign up box with the invite_email from the invite' do
click_link 'Register now'
expect(find_field('Email').value).to eq(group_invite.invite_email) expect(find_field('Email').value).to eq(group_invite.invite_email)
end end
it 'sign in, grants access and redirects to group page' do it 'sign in, grants access and redirects to group page' do
click_link 'Sign in'
fill_in_sign_in_form(user) fill_in_sign_in_form(user)
expect(current_path).to eq(group_path(group)) expect(current_path).to eq(group_path(group))
...@@ -85,20 +87,19 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do ...@@ -85,20 +87,19 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end end
end end
context 'when inviting a user' do context 'when inviting an unregistered user' do
let(:new_user) { build_stubbed(:user) } let(:new_user) { build_stubbed(:user) }
let(:invite_email) { new_user.email } let(:invite_email) { new_user.email }
let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email, created_by: owner) } let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email, created_by: owner) }
let!(:project_invite) { create(:project_member, :invited, project: project, invite_email: invite_email) } let!(:project_invite) { create(:project_member, :invited, project: project, invite_email: invite_email) }
context 'when user has not signed in yet' do context 'when registering using invitation email' do
before do before do
stub_application_setting(send_user_confirmation_email: send_email_confirmation) stub_application_setting(send_user_confirmation_email: send_email_confirmation)
visit invite_path(group_invite.raw_invite_token) visit invite_path(group_invite.raw_invite_token)
click_link 'Register now'
end end
context 'with admin appoval required enabled' do context 'with admin approval required enabled' do
before do before do
stub_application_setting(require_admin_approval_after_user_signup: true) stub_application_setting(require_admin_approval_after_user_signup: true)
end end
......
...@@ -117,12 +117,6 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -117,12 +117,6 @@ RSpec.describe 'GFM autocomplete', :js do
end end
end end
it 'opens autocomplete menu when field starts with text' do
fill_in 'Comment', with: '@'
expect(find_autocomplete_menu).to be_visible
end
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do
issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;' issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;'
create(:issue, project: project, title: issue_xss_title) create(:issue, project: project, title: issue_xss_title)
...@@ -153,74 +147,87 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -153,74 +147,87 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find_autocomplete_menu).to have_text('alert milestone') expect(find_autocomplete_menu).to have_text('alert milestone')
end end
it 'doesnt select the first item for non-assignee dropdowns' do describe 'autocomplete highlighting' do
fill_in 'Comment', with: ':' it 'auto-selects the first item when there is a query, and only for assignees with no query', :aggregate_failures do
fill_in 'Comment', with: ':'
wait_for_requests
expect(find_autocomplete_menu).not_to have_css('.cur')
wait_for_requests fill_in 'Comment', with: ':1'
wait_for_requests
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
expect(find_autocomplete_menu).not_to have_css('.cur') fill_in 'Comment', with: '@'
wait_for_requests
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
end
end end
it 'selects the first item for assignee dropdowns' do describe 'assignees' do
fill_in 'Comment', with: '@' it 'does not wrap with quotes for assignee values' do
fill_in 'Comment', with: "@#{user.username[0]}"
wait_for_requests find_highlighted_autocomplete_item.click
expect(find_autocomplete_menu).to have_css('.cur:first-of-type') expect(find_field('Comment').value).to have_text("@#{user.username}")
end end
it 'includes items for assignee dropdowns with non-ASCII characters in name' do it 'includes items for assignee dropdowns with non-ASCII characters in name' do
fill_in 'Comment', with: "@#{user.name[0...8]}" fill_in 'Comment', with: "@#{user.name[0...8]}"
wait_for_requests wait_for_requests
expect(find_autocomplete_menu).to have_text(user.name) expect(find_autocomplete_menu).to have_text(user.name)
end end
it 'searches across full name for assignees' do it 'searches across full name for assignees' do
fill_in 'Comment', with: '@speciąlsome' fill_in 'Comment', with: '@speciąlsome'
wait_for_requests wait_for_requests
expect(find_highlighted_autocomplete_item).to have_text(user.name) expect(find_highlighted_autocomplete_item).to have_text(user.name)
end end
it 'shows names that start with the query as the top result' do it 'shows names that start with the query as the top result' do
fill_in 'Comment', with: '@mar' fill_in 'Comment', with: '@mar'
wait_for_requests wait_for_requests
expect(find_highlighted_autocomplete_item).to have_text(user2.name) expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end end
it 'shows usernames that start with the query as the top result' do it 'shows usernames that start with the query as the top result' do
fill_in 'Comment', with: '@msi' fill_in 'Comment', with: '@msi'
wait_for_requests wait_for_requests
expect(find_highlighted_autocomplete_item).to have_text(user2.name) expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end end
# Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925 # Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925
it 'shows username when pasting then pressing Enter' do it 'shows username when pasting then pressing Enter' do
fill_in 'Comment', with: "@#{user.username}\n" fill_in 'Comment', with: "@#{user.username}\n"
expect(find_field('Comment').value).to have_text "@#{user.username}" expect(find_field('Comment').value).to have_text "@#{user.username}"
end end
it 'does not show `@undefined` when pressing `@` then Enter' do it 'does not show `@undefined` when pressing `@` then Enter' do
fill_in 'Comment', with: "@\n" fill_in 'Comment', with: "@\n"
expect(find_field('Comment').value).to have_text '@' expect(find_field('Comment').value).to have_text '@'
expect(find_field('Comment').value).not_to have_text '@undefined' expect(find_field('Comment').value).not_to have_text '@undefined'
end end
it 'selects the first item for non-assignee dropdowns if a query is entered' do context 'when /assign quick action is selected' do
fill_in 'Comment', with: ':1' it 'triggers user autocomplete and lists users who are currently not assigned to the issue' do
fill_in 'Comment', with: '/as'
wait_for_requests find_highlighted_autocomplete_item.click
expect(find_autocomplete_menu).to have_css('.cur:first-of-type') expect(find_autocomplete_menu).not_to have_text(user.username)
expect(find_autocomplete_menu).to have_text(user2.username)
end
end
end end
context 'if a selected value has special characters' do context 'if a selected value has special characters' do
...@@ -232,14 +239,6 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -232,14 +239,6 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find_field('Comment').value).to have_text("~\"#{label.title}\"") expect(find_field('Comment').value).to have_text("~\"#{label.title}\"")
end end
it 'doesn\'t wrap for assignee values' do
fill_in 'Comment', with: "@#{user.username[0]}"
find_highlighted_autocomplete_item.click
expect(find_field('Comment').value).to have_text("@#{user.username}")
end
it 'doesn\'t wrap for emoji values' do it 'doesn\'t wrap for emoji values' do
fill_in 'Comment', with: ':cartwheel_' fill_in 'Comment', with: ':cartwheel_'
...@@ -263,17 +262,6 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -263,17 +262,6 @@ RSpec.describe 'GFM autocomplete', :js do
end end
end end
context 'assignees' do
it 'lists users who are currently not assigned to the issue when using /assign' do
fill_in 'Comment', with: '/as'
find_highlighted_autocomplete_item.click
expect(find_autocomplete_menu).not_to have_text(user.username)
expect(find_autocomplete_menu).to have_text(user2.username)
end
end
context 'labels' do context 'labels' do
it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do
label_xss_title = 'alert label &lt;img src=x onerror="alert(\'Hello xss\');" a' label_xss_title = 'alert label &lt;img src=x onerror="alert(\'Hello xss\');" a'
...@@ -498,12 +486,6 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -498,12 +486,6 @@ RSpec.describe 'GFM autocomplete', :js do
end end
end end
it 'opens autocomplete menu when field starts with text' do
fill_in 'Comment', with: '@'
expect(find_tribute_autocomplete_menu).to be_visible
end
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do
issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;' issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;'
create(:issue, project: project, title: issue_xss_title) create(:issue, project: project, title: issue_xss_title)
...@@ -534,41 +516,77 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -534,41 +516,77 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find_tribute_autocomplete_menu).to have_text('alert milestone') expect(find_tribute_autocomplete_menu).to have_text('alert milestone')
end end
it 'selects the first item for assignee dropdowns' do describe 'autocomplete highlighting' do
fill_in 'Comment', with: '@' it 'auto-selects the first item with query', :aggregate_failures do
fill_in 'Comment', with: ':1'
wait_for_requests wait_for_requests
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type') fill_in 'Comment', with: '@'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
end
end end
it 'includes items for assignee dropdowns with non-ASCII characters in name' do describe 'assignees' do
fill_in 'Comment', with: "@#{user.name[0...8]}" it 'does not wrap with quotes for assignee values' do
fill_in 'Comment', with: "@#{user.username[0..2]}"
wait_for_requests find_highlighted_tribute_autocomplete_menu.click
expect(find_tribute_autocomplete_menu).to have_text(user.name) expect(find_field('Comment').value).to have_text("@#{user.username}")
end end
it 'selects the first item for non-assignee dropdowns if a query is entered' do it 'includes items for assignee dropdowns with non-ASCII characters in name' do
fill_in 'Comment', with: ':1' fill_in 'Comment', with: "@#{user.name[0...8]}"
wait_for_requests wait_for_requests
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type') expect(find_tribute_autocomplete_menu).to have_text(user.name)
end end
context 'when autocompleting for groups' do
it 'shows the group when searching for the name of the group' do
fill_in 'Comment', with: '@mygroup'
context 'when autocompleting for groups' do expect(find_tribute_autocomplete_menu).to have_text('My group')
it 'shows the group when searching for the name of the group' do end
fill_in 'Comment', with: '@mygroup'
it 'does not show the group when searching for the name of the parent of the group' do
fill_in 'Comment', with: '@ancestor'
expect(find_tribute_autocomplete_menu).to have_text('My group') expect(find_tribute_autocomplete_menu).not_to have_text('My group')
end
end end
it 'does not show the group when searching for the name of the parent of the group' do context 'when /assign quick action is selected' do
fill_in 'Comment', with: '@ancestor' it 'lists users who are currently not assigned to the issue' do
note = find_field('Comment')
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
expect(find_tribute_autocomplete_menu).not_to have_text('My group') expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
expect(find_tribute_autocomplete_menu).to have_text(user2.username)
end
it 'lists users who are currently not assigned to the issue when using /assign on the second line' do
note = find_field('Comment')
note.native.send_keys('/assign @user2')
note.native.send_keys(:enter)
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
expect(find_tribute_autocomplete_menu).to have_text(user2.username)
end
end end
end end
...@@ -581,14 +599,6 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -581,14 +599,6 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find_field('Comment').value).to have_text("~\"#{label.title}\"") expect(find_field('Comment').value).to have_text("~\"#{label.title}\"")
end end
it 'doesn\'t wrap for assignee values' do
fill_in 'Comment', with: "@#{user.username[0..2]}"
find_highlighted_tribute_autocomplete_menu.click
expect(find_field('Comment').value).to have_text("@#{user.username}")
end
it 'does not wrap for emoji values' do it 'does not wrap for emoji values' do
fill_in 'Comment', with: ':cartwheel_' fill_in 'Comment', with: ':cartwheel_'
...@@ -606,36 +616,6 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -606,36 +616,6 @@ RSpec.describe 'GFM autocomplete', :js do
end end
end end
context 'assignees' do
it 'lists users who are currently not assigned to the issue when using /assign' do
note = find_field('Comment')
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
expect(find_tribute_autocomplete_menu).to have_text(user2.username)
end
it 'lists users who are currently not assigned to the issue when using /assign on the second line' do
note = find_field('Comment')
note.native.send_keys('/assign @user2')
note.native.send_keys(:enter)
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
expect(find_tribute_autocomplete_menu).to have_text(user2.username)
end
end
context 'labels' do context 'labels' do
it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do
label_xss_title = 'alert label &lt;img src=x onerror="alert(\'Hello xss\');" a' label_xss_title = 'alert label &lt;img src=x onerror="alert(\'Hello xss\');" a'
......
...@@ -37,7 +37,7 @@ RSpec.describe 'User edits Release', :js do ...@@ -37,7 +37,7 @@ RSpec.describe 'User edits Release', :js do
end end
it 'renders the edit Release form' do it 'renders the edit Release form' do
expect(page).to have_content('Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0, v2.0-pre.') expect(page).to have_content('Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0.0, v2.1.0-pre.')
expect(find_field('Tag name', disabled: true).value).to eq(release.tag) expect(find_field('Tag name', disabled: true).value).to eq(release.tag)
expect(find_field('Release title').value).to eq(release.name) expect(find_field('Release title').value).to eq(release.name)
......
import { GlButton } from '@gitlab/ui'; import { GlButton, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import ExperimentTracking from '~/experimentation/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
...@@ -8,22 +8,29 @@ jest.mock('~/experimentation/experiment_tracking'); ...@@ -8,22 +8,29 @@ jest.mock('~/experimentation/experiment_tracking');
const displayText = 'Invite team members'; const displayText = 'Invite team members';
let wrapper; let wrapper;
let triggerProps;
let findButton;
const triggerComponent = {
button: GlButton,
anchor: GlLink,
};
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMount(InviteMembersTrigger, { wrapper = shallowMount(InviteMembersTrigger, {
propsData: { propsData: {
displayText, displayText,
...triggerProps,
...props, ...props,
}, },
}); });
}; };
describe('InviteMembersTrigger', () => { describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement) => {
const findButton = () => wrapper.findComponent(GlButton); triggerProps = { triggerElement };
findButton = () => wrapper.findComponent(triggerComponent[triggerElement]);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('displayText', () => { describe('displayText', () => {
...@@ -74,5 +81,19 @@ describe('InviteMembersTrigger', () => { ...@@ -74,5 +81,19 @@ describe('InviteMembersTrigger', () => {
expect(ExperimentTracking).not.toHaveBeenCalledWith('_track_experiment_'); expect(ExperimentTracking).not.toHaveBeenCalledWith('_track_experiment_');
}); });
it('does not add tracking attributes', () => {
createComponent();
expect(findButton().attributes('data-track-event')).toBeUndefined();
expect(findButton().attributes('data-track-label')).toBeUndefined();
});
it('adds tracking attributes', () => {
createComponent({ label: '_label_', event: '_event_' });
expect(findButton().attributes('data-track-event')).toBe('_event_');
expect(findButton().attributes('data-track-label')).toBe('_label_');
});
}); });
}); });
...@@ -7,6 +7,8 @@ import PackageSearch from '~/packages/list/components/package_search.vue'; ...@@ -7,6 +7,8 @@ import PackageSearch from '~/packages/list/components/package_search.vue';
import PackageListApp from '~/packages/list/components/packages_list_app.vue'; import PackageListApp from '~/packages/list/components/packages_list_app.vue';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import * as packageUtils from '~/packages_and_registries/shared/utils';
jest.mock('~/lib/utils/common_utils'); jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -61,6 +63,7 @@ describe('packages_list_app', () => { ...@@ -61,6 +63,7 @@ describe('packages_list_app', () => {
beforeEach(() => { beforeEach(() => {
createStore(); createStore();
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({});
}); });
afterEach(() => { afterEach(() => {
...@@ -72,25 +75,6 @@ describe('packages_list_app', () => { ...@@ -72,25 +75,6 @@ describe('packages_list_app', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
describe('empty state', () => {
it('generate the correct empty list link', () => {
mountComponent();
const link = findListComponent().find(GlLink);
expect(link.attributes('href')).toBe(emptyListHelpUrl);
expect(link.text()).toBe('publish and share your packages');
});
it('includes the right content on the default tab', () => {
mountComponent();
const heading = findEmptyState().find('h1');
expect(heading.text()).toBe('There are no packages yet');
});
});
it('call requestPackagesList on page:changed', () => { it('call requestPackagesList on page:changed', () => {
mountComponent(); mountComponent();
store.dispatch.mockClear(); store.dispatch.mockClear();
...@@ -108,10 +92,75 @@ describe('packages_list_app', () => { ...@@ -108,10 +92,75 @@ describe('packages_list_app', () => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo'); expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo');
}); });
it('does not call requestPackagesList two times on render', () => { it('does call requestPackagesList only one time on render', () => {
mountComponent(); mountComponent();
expect(store.dispatch).toHaveBeenCalledTimes(1); expect(store.dispatch).toHaveBeenCalledTimes(3);
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', expect.any(Object));
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', expect.any(Array));
expect(store.dispatch).toHaveBeenNthCalledWith(3, 'requestPackagesList');
});
describe('url query string handling', () => {
const defaultQueryParamsMock = {
search: [1, 2],
type: 'npm',
sort: 'asc',
orderBy: 'created',
};
it('calls setSorting with the query string based sorting', () => {
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock);
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', {
orderBy: defaultQueryParamsMock.orderBy,
sort: defaultQueryParamsMock.sort,
});
});
it('calls setFilter with the query string based filters', () => {
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock);
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', [
{ type: 'type', value: { data: defaultQueryParamsMock.type } },
{ type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[0] } },
{ type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[1] } },
]);
});
it('calls setSorting and setFilters with the results of extractFilterAndSorting', () => {
jest
.spyOn(packageUtils, 'extractFilterAndSorting')
.mockReturnValue({ filters: ['foo'], sorting: { sort: 'desc' } });
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { sort: 'desc' });
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', ['foo']);
});
});
describe('empty state', () => {
it('generate the correct empty list link', () => {
mountComponent();
const link = findListComponent().find(GlLink);
expect(link.attributes('href')).toBe(emptyListHelpUrl);
expect(link.text()).toBe('publish and share your packages');
});
it('includes the right content on the default tab', () => {
mountComponent();
const heading = findEmptyState().find('h1');
expect(heading.text()).toBe('There are no packages yet');
});
}); });
describe('filter without results', () => { describe('filter without results', () => {
......
...@@ -4,6 +4,7 @@ import component from '~/packages/list/components/package_search.vue'; ...@@ -4,6 +4,7 @@ import component from '~/packages/list/components/package_search.vue';
import PackageTypeToken from '~/packages/list/components/tokens/package_type_token.vue'; import PackageTypeToken from '~/packages/list/components/tokens/package_type_token.vue';
import getTableHeaders from '~/packages/list/utils'; import getTableHeaders from '~/packages/list/utils';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -12,7 +13,8 @@ describe('Package Search', () => { ...@@ -12,7 +13,8 @@ describe('Package Search', () => {
let wrapper; let wrapper;
let store; let store;
const findRegistrySearch = () => wrapper.find(RegistrySearch); const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
const findUrlSync = () => wrapper.findComponent(UrlSync);
const createStore = (isGroupPage) => { const createStore = (isGroupPage) => {
const state = { const state = {
...@@ -37,6 +39,9 @@ describe('Package Search', () => { ...@@ -37,6 +39,9 @@ describe('Package Search', () => {
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
localVue, localVue,
store, store,
stubs: {
UrlSync,
},
}); });
}; };
...@@ -104,4 +109,20 @@ describe('Package Search', () => { ...@@ -104,4 +109,20 @@ describe('Package Search', () => {
expect(wrapper.emitted('update')).toEqual([[]]); expect(wrapper.emitted('update')).toEqual([[]]);
}); });
it('has a UrlSync component', () => {
mountComponent();
expect(findUrlSync().exists()).toBe(true);
});
it('on query:changed calls updateQuery from UrlSync', () => {
jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {});
mountComponent();
findRegistrySearch().vm.$emit('query:changed');
expect(UrlSync.methods.updateQuery).toHaveBeenCalled();
});
}); });
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { import {
getQueryParams, getQueryParams,
keyValueToFilterToken, keyValueToFilterToken,
searchArrayToFilterTokens, searchArrayToFilterTokens,
extractFilterAndSorting,
} from '~/packages_and_registries/shared/utils'; } from '~/packages_and_registries/shared/utils';
describe('Packages And Registries shared utils', () => { describe('Packages And Registries shared utils', () => {
...@@ -27,9 +29,31 @@ describe('Packages And Registries shared utils', () => { ...@@ -27,9 +29,31 @@ describe('Packages And Registries shared utils', () => {
const search = ['one', 'two']; const search = ['one', 'two'];
expect(searchArrayToFilterTokens(search)).toStrictEqual([ expect(searchArrayToFilterTokens(search)).toStrictEqual([
{ type: 'filtered-search-term', value: { data: 'one' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } },
{ type: 'filtered-search-term', value: { data: 'two' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'two' } },
]); ]);
}); });
}); });
describe('extractFilterAndSorting', () => {
it.each`
search | type | sort | orderBy | result
${['one']} | ${'myType'} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: 'type', value: { data: 'myType' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
${['one']} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
${[]} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${null} | ${'foo'} | ${{ sorting: { orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${null} | ${null} | ${{ sorting: {}, filters: [] }}
`(
'returns sorting and filters objects in the correct form',
({ search, type, sort, orderBy, result }) => {
const queryObject = {
search,
type,
sort,
orderBy,
};
expect(extractFilterAndSorting(queryObject)).toStrictEqual(result);
},
);
});
}); });
import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui'; import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
...@@ -61,7 +62,7 @@ describe('List Page', () => { ...@@ -61,7 +62,7 @@ describe('List Page', () => {
const waitForApolloRequestRender = async () => { const waitForApolloRequestRender = async () => {
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
await waitForPromises(); await waitForPromises();
await wrapper.vm.$nextTick(); await nextTick();
}; };
const mountComponent = ({ const mountComponent = ({
...@@ -70,6 +71,7 @@ describe('List Page', () => { ...@@ -70,6 +71,7 @@ describe('List Page', () => {
detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock), detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock),
mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock), mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock),
config = { isGroupPage: false }, config = { isGroupPage: false },
query = {},
} = {}) => { } = {}) => {
localVue.use(VueApollo); localVue.use(VueApollo);
...@@ -96,6 +98,7 @@ describe('List Page', () => { ...@@ -96,6 +98,7 @@ describe('List Page', () => {
$toast, $toast,
$route: { $route: {
name: 'foo', name: 'foo',
query,
}, },
...mocks, ...mocks,
}, },
...@@ -159,9 +162,11 @@ describe('List Page', () => { ...@@ -159,9 +162,11 @@ describe('List Page', () => {
}); });
describe('isLoading is true', () => { describe('isLoading is true', () => {
it('shows the skeleton loader', () => { it('shows the skeleton loader', async () => {
mountComponent(); mountComponent();
await nextTick();
expect(findSkeletonLoader().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(true);
}); });
...@@ -177,9 +182,11 @@ describe('List Page', () => { ...@@ -177,9 +182,11 @@ describe('List Page', () => {
expect(findCliCommands().exists()).toBe(false); expect(findCliCommands().exists()).toBe(false);
}); });
it('title has the metadataLoading props set to true', () => { it('title has the metadataLoading props set to true', async () => {
mountComponent(); mountComponent();
await nextTick();
expect(findRegistryHeader().props('metadataLoading')).toBe(true); expect(findRegistryHeader().props('metadataLoading')).toBe(true);
}); });
}); });
...@@ -312,7 +319,7 @@ describe('List Page', () => { ...@@ -312,7 +319,7 @@ describe('List Page', () => {
await selectImageForDeletion(); await selectImageForDeletion();
findDeleteImage().vm.$emit('success'); findDeleteImage().vm.$emit('success');
await wrapper.vm.$nextTick(); await nextTick();
const alert = findDeleteAlert(); const alert = findDeleteAlert();
expect(alert.exists()).toBe(true); expect(alert.exists()).toBe(true);
...@@ -328,7 +335,7 @@ describe('List Page', () => { ...@@ -328,7 +335,7 @@ describe('List Page', () => {
await selectImageForDeletion(); await selectImageForDeletion();
findDeleteImage().vm.$emit('error'); findDeleteImage().vm.$emit('error');
await wrapper.vm.$nextTick(); await nextTick();
const alert = findDeleteAlert(); const alert = findDeleteAlert();
expect(alert.exists()).toBe(true); expect(alert.exists()).toBe(true);
...@@ -349,7 +356,7 @@ describe('List Page', () => { ...@@ -349,7 +356,7 @@ describe('List Page', () => {
findRegistrySearch().vm.$emit('filter:submit'); findRegistrySearch().vm.$emit('filter:submit');
await wrapper.vm.$nextTick(); await nextTick();
}; };
it('has a search box element', async () => { it('has a search box element', async () => {
...@@ -374,7 +381,7 @@ describe('List Page', () => { ...@@ -374,7 +381,7 @@ describe('List Page', () => {
await waitForApolloRequestRender(); await waitForApolloRequestRender();
findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' }); findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' });
await wrapper.vm.$nextTick(); await nextTick();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' })); expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' }));
}); });
...@@ -417,7 +424,7 @@ describe('List Page', () => { ...@@ -417,7 +424,7 @@ describe('List Page', () => {
await waitForApolloRequestRender(); await waitForApolloRequestRender();
findImageList().vm.$emit('prev-page'); findImageList().vm.$emit('prev-page');
await wrapper.vm.$nextTick(); await nextTick();
expect(resolver).toHaveBeenCalledWith( expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ before: pageInfo.startCursor }), expect.objectContaining({ before: pageInfo.startCursor }),
...@@ -437,7 +444,7 @@ describe('List Page', () => { ...@@ -437,7 +444,7 @@ describe('List Page', () => {
await waitForApolloRequestRender(); await waitForApolloRequestRender();
findImageList().vm.$emit('next-page'); findImageList().vm.$emit('next-page');
await wrapper.vm.$nextTick(); await nextTick();
expect(resolver).toHaveBeenCalledWith( expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ after: pageInfo.endCursor }), expect.objectContaining({ after: pageInfo.endCursor }),
...@@ -458,11 +465,10 @@ describe('List Page', () => { ...@@ -458,11 +465,10 @@ describe('List Page', () => {
expect(findDeleteModal().exists()).toBe(true); expect(findDeleteModal().exists()).toBe(true);
}); });
it('contains a description with the path of the item to delete', () => { it('contains a description with the path of the item to delete', async () => {
findImageList().vm.$emit('delete', { path: 'foo' }); findImageList().vm.$emit('delete', { path: 'foo' });
return wrapper.vm.$nextTick().then(() => { await nextTick();
expect(findDeleteModal().html()).toContain('foo'); expect(findDeleteModal().html()).toContain('foo');
});
}); });
}); });
...@@ -498,4 +504,60 @@ describe('List Page', () => { ...@@ -498,4 +504,60 @@ describe('List Page', () => {
testTrackingCall('confirm_delete'); testTrackingCall('confirm_delete');
}); });
}); });
describe('url query string handling', () => {
const defaultQueryParams = {
search: [1, 2],
sort: 'asc',
orderBy: 'CREATED',
};
const queryChangePayload = 'foo';
it('query:updated event pushes the new query to the router', async () => {
const push = jest.fn();
mountComponent({ mocks: { $router: { push } } });
await nextTick();
findRegistrySearch().vm.$emit('query:changed', queryChangePayload);
expect(push).toHaveBeenCalledWith({ query: queryChangePayload });
});
it('graphql API call has the variables set from the URL', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ query: defaultQueryParams, resolver });
await nextTick();
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({
name: 1,
sort: 'CREATED_ASC',
}),
);
});
it.each`
sort | orderBy | search | payload
${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }}
${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }}
${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }}
${undefined} | ${undefined} | ${undefined} | ${{}}
${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }}
${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }}
${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }}
${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }}
`(
'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload',
async ({ sort, orderBy, search, payload }) => {
const resolver = jest.fn().mockResolvedValue({ sort, orderBy });
mountComponent({ query: { sort, orderBy, search }, resolver });
await nextTick();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload));
},
);
});
}); });
...@@ -112,7 +112,7 @@ describe('Release edit/new component', () => { ...@@ -112,7 +112,7 @@ describe('Release edit/new component', () => {
it('renders the description text at the top of the page', () => { it('renders the description text at the top of the page', () => {
expect(wrapper.find('.js-subtitle-text').text()).toBe( expect(wrapper.find('.js-subtitle-text').text()).toBe(
'Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0, v2.0-pre.', 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0.0, v2.1.0-pre.',
); );
}); });
......
...@@ -76,6 +76,7 @@ describe('Blob content viewer component', () => { ...@@ -76,6 +76,7 @@ describe('Blob content viewer component', () => {
expect(findBlobContent().props('loading')).toEqual(false); expect(findBlobContent().props('loading')).toEqual(false);
expect(findBlobContent().props('content')).toEqual('raw content'); expect(findBlobContent().props('content')).toEqual('raw content');
expect(findBlobContent().props('isRawContent')).toBe(true);
expect(findBlobContent().props('activeViewer')).toEqual({ expect(findBlobContent().props('activeViewer')).toEqual({
fileType: 'text', fileType: 'text',
tooLarge: false, tooLarge: false,
......
import { shallowMount } from '@vue/test-utils';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import BlobPage from '~/repository/pages/blob.vue';
jest.mock('~/repository/utils/dom');
describe('Repository blob page component', () => {
let wrapper;
const findBlobContentViewer = () => wrapper.find(BlobContentViewer);
const path = 'file.js';
beforeEach(() => {
wrapper = shallowMount(BlobPage, { propsData: { path } });
});
afterEach(() => {
wrapper.destroy();
});
it('has a Blob Content Viewer component', () => {
expect(findBlobContentViewer().exists()).toBe(true);
expect(findBlobContentViewer().props('path')).toBe(path);
});
});
...@@ -29,7 +29,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = ...@@ -29,7 +29,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
> >
<markdown-header-stub <markdown-header-stub
linecontent="" linecontent=""
suggestionstartindex="-1" suggestionstartindex="0"
/> />
<div <div
......
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = ` exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = `
<div <div>
class="file-content code js-syntax-highlight"
data-qa-selector="file_content"
>
<div <div
class="line-numbers" class="file-content code js-syntax-highlight"
data-qa-selector="file_content"
> >
<a <div
class="diff-line-num js-line-number" class="line-numbers"
data-line-number="1"
href="#LC1"
id="L1"
> >
<gl-icon-stub <a
name="link" class="diff-line-num js-line-number"
size="12" data-line-number="1"
/> href="#LC1"
id="L1"
>
<gl-icon-stub
name="link"
size="12"
/>
1
1 </a>
<a
</a> class="diff-line-num js-line-number"
<a data-line-number="2"
class="diff-line-num js-line-number" href="#LC2"
data-line-number="2" id="L2"
href="#LC2" >
id="L2" <gl-icon-stub
> name="link"
<gl-icon-stub size="12"
name="link" />
size="12"
/> 2
2 </a>
<a
</a> class="diff-line-num js-line-number"
<a data-line-number="3"
class="diff-line-num js-line-number" href="#LC3"
data-line-number="3" id="L3"
href="#LC3" >
id="L3" <gl-icon-stub
> name="link"
<gl-icon-stub size="12"
name="link" />
size="12"
/> 3
3 </a>
</div>
</a>
</div> <div
class="blob-content"
<div
class="blob-content"
>
<pre
class="code highlight"
> >
<code <pre
data-blob-hash="foo-bar" class="code highlight"
> >
<span <code
id="LC1" data-blob-hash="foo-bar"
> >
First <span
</span> id="LC1"
>
First
</span>
<span <span
id="LC2" id="LC2"
> >
Second Second
</span> </span>
<span <span
id="LC3" id="LC3"
> >
Third Third
</span> </span>
</code> </code>
</pre> </pre>
</div>
</div> </div>
</div> </div>
`; `;
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants'; import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants';
import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue'; import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
describe('Blob Simple Viewer component', () => { describe('Blob Simple Viewer component', () => {
let wrapper; let wrapper;
const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`; const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`;
const blobHash = 'foo-bar'; const blobHash = 'foo-bar';
function createComponent(content = contentMock) { function createComponent(content = contentMock, isRawContent = false) {
wrapper = shallowMount(SimpleViewer, { wrapper = shallowMount(SimpleViewer, {
provide: { provide: {
blobHash, blobHash,
...@@ -15,6 +16,8 @@ describe('Blob Simple Viewer component', () => { ...@@ -15,6 +16,8 @@ describe('Blob Simple Viewer component', () => {
propsData: { propsData: {
content, content,
type: 'text', type: 'text',
fileName: 'test.js',
isRawContent,
}, },
}); });
} }
...@@ -83,4 +86,18 @@ describe('Blob Simple Viewer component', () => { ...@@ -83,4 +86,18 @@ describe('Blob Simple Viewer component', () => {
}); });
}); });
}); });
describe('raw content', () => {
const findEditorLite = () => wrapper.find(EditorLite);
const isRawContent = true;
it('uses the Editor Lite component in readonly mode when viewing raw content', () => {
createComponent('raw content', isRawContent);
expect(findEditorLite().exists()).toBe(true);
expect(findEditorLite().props('value')).toBe('raw content');
expect(findEditorLite().props('fileName')).toBe('test.js');
expect(findEditorLite().props('editorOptions')).toEqual({ readOnly: true });
});
});
}); });
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::Common::Extractors::RestExtractor do
let(:http_client) { instance_double(BulkImports::Clients::Http) }
let(:options) { { query: double(to_h: { resource: nil, query: nil }) } }
let(:response) { double(parsed_response: { 'data' => { 'foo' => 'bar' } }, headers: { 'x-next-page' => '2' }) }
subject { described_class.new(options) }
describe '#extract' do
before do
allow(subject).to receive(:http_client).and_return(http_client)
allow(http_client).to receive(:get).and_return(response)
end
it 'returns instance of ExtractedData' do
entity = create(:bulk_import_entity)
tracker = create(:bulk_import_tracker, entity: entity)
context = BulkImports::Pipeline::Context.new(tracker)
extracted_data = subject.extract(context)
expect(extracted_data).to be_instance_of(BulkImports::Pipeline::ExtractedData)
expect(extracted_data.data).to contain_exactly(response.parsed_response)
expect(extracted_data.next_page).to eq(response.headers['x-next-page'])
expect(extracted_data.has_next_page?).to eq(true)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::Groups::Pipelines::BadgesPipeline do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:entity) do
create(
:bulk_import_entity,
source_full_path: 'source/full/path',
destination_name: 'My Destination Group',
destination_namespace: group.full_path,
group: group
)
end
let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
subject { described_class.new(context) }
describe '#run' do
it 'imports a group badge' do
first_page = extracted_data(has_next_page: true)
last_page = extracted_data(name: 'badge2')
allow_next_instance_of(BulkImports::Common::Extractors::RestExtractor) do |extractor|
allow(extractor)
.to receive(:extract)
.and_return(first_page, last_page)
end
expect { subject.run }.to change(Badge, :count).by(2)
badge = group.badges.last
expect(badge.name).to eq('badge2')
expect(badge.link_url).to eq(badge_data['link_url'])
expect(badge.image_url).to eq(badge_data['image_url'])
end
describe '#load' do
it 'creates a badge' do
expect { subject.load(context, badge_data) }.to change(Badge, :count).by(1)
badge = group.badges.first
badge_data.each do |key, value|
expect(badge[key]).to eq(value)
end
end
it 'does nothing when the data is blank' do
expect { subject.load(context, nil) }.not_to change(Badge, :count)
end
end
describe '#transform' do
it 'return transformed badge hash' do
badge = subject.transform(context, badge_data)
expect(badge[:name]).to eq('badge')
expect(badge[:link_url]).to eq(badge_data['link_url'])
expect(badge[:image_url]).to eq(badge_data['image_url'])
expect(badge.keys).to contain_exactly(:name, :link_url, :image_url)
end
context 'when data is blank' do
it 'does nothing when the data is blank' do
expect(subject.transform(context, nil)).to be_nil
end
end
end
describe 'pipeline parts' do
it { expect(described_class).to include_module(BulkImports::Pipeline) }
it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) }
it 'has extractors' do
expect(described_class.get_extractor)
.to eq(
klass: BulkImports::Common::Extractors::RestExtractor,
options: {
query: BulkImports::Groups::Rest::GetBadgesQuery
}
)
end
it 'has transformers' do
expect(described_class.transformers)
.to contain_exactly(
{ klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil }
)
end
end
def badge_data(name = 'badge')
{
'name' => name,
'link_url' => 'https://gitlab.example.com',
'image_url' => 'https://gitlab.example.com/image.png'
}
end
def extracted_data(name: 'badge', has_next_page: false)
page_info = {
'has_next_page' => has_next_page,
'next_page' => has_next_page ? '2' : nil
}
BulkImports::Pipeline::ExtractedData.new(data: [badge_data(name)], page_info: page_info)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::Groups::Rest::GetBadgesQuery do
describe '.to_h' do
it 'returns query resource and page info' do
entity = create(:bulk_import_entity)
tracker = create(:bulk_import_tracker, entity: entity)
context = BulkImports::Pipeline::Context.new(tracker)
encoded_full_path = ERB::Util.url_encode(entity.source_full_path)
expected = {
resource: ['groups', encoded_full_path, 'badges'].join('/'),
query: {
page: context.tracker.next_page
}
}
expect(described_class.to_h(context)).to eq(expected)
end
end
end
...@@ -23,6 +23,7 @@ RSpec.describe BulkImports::Importers::GroupImporter do ...@@ -23,6 +23,7 @@ RSpec.describe BulkImports::Importers::GroupImporter do
expect_to_run_pipeline BulkImports::Groups::Pipelines::MembersPipeline, context: context expect_to_run_pipeline BulkImports::Groups::Pipelines::MembersPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::LabelsPipeline, context: context expect_to_run_pipeline BulkImports::Groups::Pipelines::LabelsPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::MilestonesPipeline, context: context expect_to_run_pipeline BulkImports::Groups::Pipelines::MilestonesPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::BadgesPipeline, context: context
if Gitlab.ee? if Gitlab.ee?
expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicsPipeline'.constantize, context: context) expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicsPipeline'.constantize, context: context)
......
...@@ -126,26 +126,6 @@ RSpec.describe 'project routing' do ...@@ -126,26 +126,6 @@ RSpec.describe 'project routing' do
it 'to #archive with "/" in route' do it 'to #archive with "/" in route' do
expect(get('/gitlab/gitlabhq/-/archive/improve/awesome/gitlabhq-improve-awesome.tar.gz')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.gz', id: 'improve/awesome/gitlabhq-improve-awesome') expect(get('/gitlab/gitlabhq/-/archive/improve/awesome/gitlabhq-improve-awesome.tar.gz')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.gz', id: 'improve/awesome/gitlabhq-improve-awesome')
end end
it 'to #archive_alternative' do
expect(get('/gitlab/gitlabhq/-/repository/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', append_sha: true)
end
it 'to #archive_deprecated' do
expect(get('/gitlab/gitlabhq/-/repository/master/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master', append_sha: true)
end
it 'to #archive_deprecated format:zip' do
expect(get('/gitlab/gitlabhq/-/repository/master/archive.zip')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'zip', id: 'master', append_sha: true)
end
it 'to #archive_deprecated format:tar.bz2' do
expect(get('/gitlab/gitlabhq/-/repository/master/archive.tar.bz2')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.bz2', id: 'master', append_sha: true)
end
it 'to #archive_deprecated with "/" in route' do
expect(get('/gitlab/gitlabhq/-/repository/improve/awesome/archive')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'improve/awesome', append_sha: true)
end
end end
describe Projects::BranchesController, 'routing' do describe Projects::BranchesController, 'routing' do
......
...@@ -6,7 +6,7 @@ require_relative '../../../../rubocop/cop/graphql/descriptions' ...@@ -6,7 +6,7 @@ require_relative '../../../../rubocop/cop/graphql/descriptions'
RSpec.describe RuboCop::Cop::Graphql::Descriptions do RSpec.describe RuboCop::Cop::Graphql::Descriptions do
subject(:cop) { described_class.new } subject(:cop) { described_class.new }
context 'fields' do context 'with fields' do
it 'adds an offense when there is no description' do it 'adds an offense when there is no description' do
expect_offense(<<~TYPE) expect_offense(<<~TYPE)
module Types module Types
...@@ -46,9 +46,19 @@ RSpec.describe RuboCop::Cop::Graphql::Descriptions do ...@@ -46,9 +46,19 @@ RSpec.describe RuboCop::Cop::Graphql::Descriptions do
end end
TYPE TYPE
end end
it 'does not add an offense when there is a resolver' do
expect_no_offenses(<<~TYPE.strip)
module Types
class FakeType < BaseObject
field :a_thing, resolver: ThingResolver
end
end
TYPE
end
end end
context 'arguments' do context 'with arguments' do
it 'adds an offense when there is no description' do it 'adds an offense when there is no description' do
expect_offense(<<~TYPE) expect_offense(<<~TYPE)
module Types module Types
...@@ -90,7 +100,7 @@ RSpec.describe RuboCop::Cop::Graphql::Descriptions do ...@@ -90,7 +100,7 @@ RSpec.describe RuboCop::Cop::Graphql::Descriptions do
end end
end end
context 'enum values' do context 'with enum values' do
it 'adds an offense when there is no description' do it 'adds an offense when there is no description' do
expect_offense(<<~TYPE) expect_offense(<<~TYPE)
module Types module Types
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
RSpec.shared_examples 'issuable invite members experiments' do RSpec.shared_examples 'issuable invite members experiments' do
context 'when a privileged user can invite' do context 'when a privileged user can invite' do
it 'shows a link for inviting members and follows through to the members page' do it 'shows a link for inviting members and launches invite modal' do
project.add_maintainer(user) project.add_maintainer(user)
visit issuable_path visit issuable_path
...@@ -11,14 +11,14 @@ RSpec.shared_examples 'issuable invite members experiments' do ...@@ -11,14 +11,14 @@ RSpec.shared_examples 'issuable invite members experiments' do
wait_for_requests wait_for_requests
page.within '.dropdown-menu-user' do page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite Members', href: project_project_members_path(project)) expect(page).to have_link('Invite Members')
expect(page).to have_selector('[data-track-event="click_invite_members"]') expect(page).to have_selector('[data-track-event="click_invite_members"]')
expect(page).to have_selector('[data-track-label="edit_assignee"]') expect(page).to have_selector('[data-track-label="edit_assignee"]')
end end
click_link 'Invite Members' click_link 'Invite Members'
expect(current_path).to eq project_project_members_path(project) expect(page).to have_content("You're inviting members to the")
end end
end end
......
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