Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-10-04 21:08:34 +00:00
parent 23dda8d4ed
commit 484a245a95
54 changed files with 861 additions and 1244 deletions

View File

@ -0,0 +1,14 @@
include:
- template: DAST-API.gitlab-ci.yml
dast_api:
variables:
DAST_API_PROFILE: Passive
DAST_API_GRAPHQL: /api/graphql
DAST_API_TARGET_URL: ${CI_ENVIRONMENT_URL}
DAST_API_OVERRIDES_ENV: "{\"headers\":{\"Authorization\":\"Bearer $REVIEW_APPS_ROOT_TOKEN\"}}"
needs: ["review-deploy"]
# Uncomment resource_group if DAST_API_PROFILE is changed to an active scan
# resource_group: dast_api_scan
rules:
- !reference [".reports:rules:schedule-dast", rules]

View File

@ -14,6 +14,7 @@ include:
- local: .gitlab/ci/review-apps/rules.gitlab-ci.yml
- local: .gitlab/ci/review-apps/qa.gitlab-ci.yml
- local: .gitlab/ci/review-apps/dast.gitlab-ci.yml
- local: .gitlab/ci/review-apps/dast-api.gitlab-ci.yml
.base-before_script: &base-before_script
- source ./scripts/utils.sh

View File

@ -357,7 +357,7 @@ gem 'warning', '~> 1.3.0'
group :development do
gem 'lefthook', '~> 1.1.1', require: false
gem 'rubocop'
gem 'solargraph', '~> 0.46.0', require: false
gem 'solargraph', '~> 0.47.2', require: false
gem 'letter_opener_web', '~> 2.0.0'
gem 'lookbook', '~> 1.0', '>= 1.0.8'

View File

@ -458,7 +458,7 @@
{"name":"redis-namespace","version":"1.9.0","platform":"ruby","checksum":"0923961f38cf15b86cb57d92507e0a3b32480729eb5033249f5de8b12e0d8612"},
{"name":"redis-rack","version":"2.1.4","platform":"ruby","checksum":"0872eecb303e483c3863d6bd0d47323d230640d41c1a4ac4a2c7596ec0b1774c"},
{"name":"redis-store","version":"1.9.1","platform":"ruby","checksum":"7b4c7438d46f7b7ce8f67fc0eda3a04fc67d32d28cf606cc98a5df4d2b77071d"},
{"name":"regexp_parser","version":"2.5.0","platform":"ruby","checksum":"a076d2d35ab8d11feab5fecf8aa09ec6df68c2429810748cba079f7b021ecde5"},
{"name":"regexp_parser","version":"2.6.0","platform":"ruby","checksum":"f163ba463a45ca2f2730e0902f2475bb0eefcd536dfc2f900a86d1e5a7d7a556"},
{"name":"regexp_property_values","version":"1.0.0","platform":"java","checksum":"5e26782b01241616855c4ee7bb8a62fce9387e484f2d3eaf04f2a0633708222e"},
{"name":"regexp_property_values","version":"1.0.0","platform":"ruby","checksum":"162499dc0bba1e66d334273a059f207a61981cc8cc69d2ca743594e7886d080f"},
{"name":"representable","version":"3.0.4","platform":"ruby","checksum":"07d43917dea4712ecebd19c1909e769deed863ad444d23ceb6461519e2cba962"},
@ -544,7 +544,7 @@
{"name":"slack-messenger","version":"2.3.4","platform":"ruby","checksum":"49c611d2be5b0f9c250a3a957b9cc09b9c07b81dacb9843642d87b6fa35609c1"},
{"name":"snaky_hash","version":"2.0.0","platform":"ruby","checksum":"fe8b2e39e8ff69320f7812af73ea06401579e29ff1734a7009567391600687de"},
{"name":"snowplow-tracker","version":"0.6.1","platform":"ruby","checksum":"9cec52fd060619f4974b3dc1f7d9a2776c5e31b668a6ead53145b9780e312314"},
{"name":"solargraph","version":"0.46.0","platform":"ruby","checksum":"1da9fd8c364501f18b0454e54506e7098bc38dae719219713fe5f246dfc91465"},
{"name":"solargraph","version":"0.47.2","platform":"ruby","checksum":"87ca4b799b9155c2c31c15954c483e952fdacd800f52d6709b901dd447bcac6a"},
{"name":"sorted_set","version":"1.0.3","platform":"java","checksum":"996283f2e5c6e838825bcdcee31d6306515ae5f24bcb0ee4ce09dfff32919b8c"},
{"name":"sorted_set","version":"1.0.3","platform":"ruby","checksum":"4f2b8bee6e8c59cbd296228c0f1f81679357177a8b6859dcc2a99e86cce6372f"},
{"name":"spamcheck","version":"1.0.0","platform":"ruby","checksum":"dfeea085184091353e17d729d2f3d714b07cba36aaf64c32dfc35ce9b466fc9c"},
@ -576,7 +576,7 @@
{"name":"text","version":"1.3.1","platform":"ruby","checksum":"2fbbbc82c1ce79c4195b13018a87cbb00d762bda39241bb3cdc32792759dd3f4"},
{"name":"thor","version":"1.2.1","platform":"ruby","checksum":"b1752153dc9c6b8d3fcaa665e9e1a00a3e73f28da5e238b81c404502e539d446"},
{"name":"thrift","version":"0.16.0","platform":"ruby","checksum":"d023286ea89e30444c9f1c28dd76107f87d8aaf85fe1742da1d8cd3b5417dcce"},
{"name":"tilt","version":"2.0.10","platform":"ruby","checksum":"9b664f0e9ae2b500cfa00f9c65c34abc6ff1799cf0034a8c0a0412d520fac866"},
{"name":"tilt","version":"2.0.11","platform":"ruby","checksum":"7b180fc472cbdeb186c85d31c0f2d1e61a2c0d77e1d9fd0ca28482a9d972d6a0"},
{"name":"timecop","version":"0.9.1","platform":"ruby","checksum":"374b543f0961dbd487e96d09ac812d4fdfeb603ec705bbff241ba060d0a9f534"},
{"name":"timeliness","version":"0.3.10","platform":"ruby","checksum":"c357233ce19dc53148e8b29dfddde134689f18f52b32928e9dfe12ebcf4a773f"},
{"name":"timfel-krb5-auth","version":"0.8.3","platform":"ruby","checksum":"ab388c9d747fa3cd95baf2cc1c03253e372d8c680adcc543670f4f099854bb80"},

View File

@ -1144,7 +1144,7 @@ GEM
redis-store (>= 1.2, < 2)
redis-store (1.9.1)
redis (>= 4, < 5)
regexp_parser (2.5.0)
regexp_parser (2.6.0)
regexp_property_values (1.0.0)
representable (3.0.4)
declarative (< 0.1.0)
@ -1333,7 +1333,7 @@ GEM
version_gem (~> 1.1)
snowplow-tracker (0.6.1)
contracts (~> 0.7, <= 0.11)
solargraph (0.46.0)
solargraph (0.47.2)
backport (~> 1.2)
benchmark
bundler (>= 1.17.2)
@ -1402,7 +1402,7 @@ GEM
text (1.3.1)
thor (1.2.1)
thrift (0.16.0)
tilt (2.0.10)
tilt (2.0.11)
timecop (0.9.1)
timeliness (0.3.10)
timfel-krb5-auth (0.8.3)
@ -1783,7 +1783,7 @@ DEPENDENCIES
simplecov-lcov (~> 0.8.0)
slack-messenger (~> 2.3.4)
snowplow-tracker (~> 0.6.1)
solargraph (~> 0.46.0)
solargraph (~> 0.47.2)
spamcheck (~> 1.0.0)
spring (~> 2.1.0)
spring-commands-rspec (~> 1.0.4)

View File

@ -397,7 +397,6 @@ export default {
:img-size="avatarSize"
class="js-no-trigger user-avatar-link"
tooltip-placement="bottom"
:enforce-gl-avatar="true"
>
<span class="js-assignee-tooltip">
<span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span>

View File

@ -97,6 +97,7 @@ export default {
eventHub.$on(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal);
eventHub.$on(`${this.action}updatePagination`, this.updatePagination);
eventHub.$on(`${this.action}updateGroups`, this.updateGroups);
eventHub.$on(`${this.action}fetchFilteredAndSortedGroups`, this.fetchFilteredAndSortedGroups);
},
mounted() {
this.fetchAllGroups();
@ -111,6 +112,7 @@ export default {
eventHub.$off(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal);
eventHub.$off(`${this.action}updatePagination`, this.updatePagination);
eventHub.$off(`${this.action}updateGroups`, this.updateGroups);
eventHub.$off(`${this.action}fetchFilteredAndSortedGroups`, this.fetchFilteredAndSortedGroups);
},
methods: {
hideModal() {
@ -153,6 +155,18 @@ export default {
this.updateGroups(res, Boolean(this.filterGroupsBy));
});
},
fetchFilteredAndSortedGroups({ filterGroupsBy, sortBy }) {
this.isLoading = true;
return this.fetchGroups({
filterGroupsBy,
sortBy,
updatePagination: true,
}).then((res) => {
this.isLoading = false;
this.updateGroups(res, Boolean(filterGroupsBy));
});
},
fetchPage({ page, filterGroupsBy, sortBy, archived }) {
this.isLoading = true;

View File

@ -1,6 +1,6 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
import { isString } from 'lodash';
import { GlTabs, GlTab, GlSearchBoxByType, GlSorting, GlSortingItem } from '@gitlab/ui';
import { isString, debounce } from 'lodash';
import { __ } from '~/locale';
import GroupsStore from '../store/groups_store';
import GroupsService from '../service/groups_service';
@ -8,12 +8,16 @@ import {
ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
ACTIVE_TAB_SHARED,
ACTIVE_TAB_ARCHIVED,
OVERVIEW_TABS_SORTING_ITEMS,
} from '../constants';
import eventHub from '../event_hub';
import GroupsApp from './app.vue';
const [SORTING_ITEM_NAME] = OVERVIEW_TABS_SORTING_ITEMS;
export default {
components: { GlTabs, GlTab, GroupsApp },
inject: ['endpoints'],
components: { GlTabs, GlTab, GroupsApp, GlSearchBoxByType, GlSorting, GlSortingItem },
inject: ['endpoints', 'initialSort'],
data() {
return {
tabs: [
@ -43,9 +47,35 @@ export default {
},
],
activeTabIndex: 0,
sort: SORTING_ITEM_NAME,
isAscending: true,
search: '',
};
},
computed: {
activeTab() {
return this.tabs[this.activeTabIndex];
},
sortQueryStringValue() {
return this.isAscending ? this.sort.asc : this.sort.desc;
},
},
watch: {
search: debounce(async function debouncedSearch() {
this.handleSearchOrSortChange();
}, 250),
},
mounted() {
this.search = this.$route.query?.filter || '';
const sortQueryStringValue = this.$route.query?.sort || this.initialSort;
const sort =
OVERVIEW_TABS_SORTING_ITEMS.find((sortOption) =>
[sortOption.asc, sortOption.desc].includes(sortQueryStringValue),
) || SORTING_ITEM_NAME;
this.sort = sort;
this.isAscending = sort.asc === sortQueryStringValue;
const activeTabIndex = this.tabs.findIndex((tab) => tab.key === this.$route.name);
if (activeTabIndex === -1) {
@ -72,14 +102,56 @@ export default {
? this.$route.params.group.split('/')
: this.$route.params.group;
this.$router.push({ name: tab.key, params: { group: groupParam } });
this.$router.push({ name: tab.key, params: { group: groupParam }, query: this.$route.query });
},
handleSearchOrSortChange() {
// Update query string
const query = {};
if (this.sortQueryStringValue !== this.initialSort) {
query.sort = this.isAscending ? this.sort.asc : this.sort.desc;
}
if (this.search) {
query.filter = this.search;
}
this.$router.push({ query });
// Reset `lazy` prop so that groups/projects are fetched with updated `sort` and `filter` params when switching tabs
this.tabs.forEach((tab, index) => {
if (index === this.activeTabIndex) {
return;
}
// eslint-disable-next-line no-param-reassign
tab.lazy = true;
});
// Update data
eventHub.$emit(`${this.activeTab.key}fetchFilteredAndSortedGroups`, {
filterGroupsBy: this.search,
sortBy: this.sortQueryStringValue,
});
},
handleSortDirectionChange() {
this.isAscending = !this.isAscending;
this.handleSearchOrSortChange();
},
handleSortingItemClick(sortingItem) {
if (sortingItem === this.sort) {
return;
}
this.sort = sortingItem;
this.handleSearchOrSortChange();
},
},
i18n: {
[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]: __('Subgroups and projects'),
[ACTIVE_TAB_SHARED]: __('Shared projects'),
[ACTIVE_TAB_ARCHIVED]: __('Archived projects'),
searchPlaceholder: __('Search'),
},
OVERVIEW_TABS_SORTING_ITEMS,
};
</script>
@ -99,5 +171,36 @@ export default {
:render-empty-state="renderEmptyState"
/>
</gl-tab>
<template #tabs-end>
<li class="gl-flex-grow-1 gl-align-self-center gl-w-full gl-lg-w-auto gl-py-2">
<div class="gl-lg-display-flex gl-justify-content-end gl-mx-n2 gl-my-n2">
<div class="gl-p-2 gl-lg-form-input-md gl-w-full">
<gl-search-box-by-type
v-model="search"
:placeholder="$options.i18n.searchPlaceholder"
data-qa-selector="groups_filter_field"
/>
</div>
<div class="gl-p-2 gl-w-full gl-lg-w-auto">
<gl-sorting
class="gl-w-full"
dropdown-class="gl-w-full"
data-testid="group_sort_by_dropdown"
:text="sort.label"
:is-ascending="isAscending"
@sortDirectionChange="handleSortDirectionChange"
>
<gl-sorting-item
v-for="sortingItem in $options.OVERVIEW_TABS_SORTING_ITEMS"
:key="sortingItem.label"
:active="sortingItem === sort"
@click="handleSortingItemClick(sortingItem)"
>{{ sortingItem.label }}</gl-sorting-item
>
</gl-sorting>
</div>
</div>
</li>
</template>
</gl-tabs>
</template>

View File

@ -62,3 +62,26 @@ export const VISIBILITY_TYPE_ICON = {
[VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
[VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
};
export const OVERVIEW_TABS_SORTING_ITEMS = [
{
label: __('Name'),
asc: 'name_asc',
desc: 'name_desc',
},
{
label: __('Created'),
asc: 'created_asc',
desc: 'created_desc',
},
{
label: __('Updated'),
asc: 'latest_activity_asc',
desc: 'latest_activity_desc',
},
{
label: __('Stars'),
asc: 'stars_asc',
desc: 'stars_desc',
},
];

View File

@ -51,6 +51,7 @@ export const initGroupOverviewTabs = () => {
subgroupsAndProjectsEndpoint,
sharedProjectsEndpoint,
archivedProjectsEndpoint,
initialSort,
} = el.dataset;
return new Vue({
@ -70,6 +71,7 @@ export const initGroupOverviewTabs = () => {
[ACTIVE_TAB_SHARED]: sharedProjectsEndpoint,
[ACTIVE_TAB_ARCHIVED]: archivedProjectsEndpoint,
},
initialSort,
},
render(createElement) {
return createElement(OverviewTabs);

View File

@ -122,14 +122,12 @@ export default {
:link-href="commit.author.webPath"
:img-src="commit.author.avatarUrl"
:img-size="32"
:img-css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (7731 epics) */"
class="gl-my-2 gl-mr-4"
/>
<user-avatar-image
v-else
class="gl-my-2 gl-mr-4"
:img-src="commit.authorGravatar || $options.defaultAvatarUrl"
:css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (7731 epics) */"
:size="32"
/>
<div

View File

@ -5,29 +5,29 @@
Sample configuration:
<user-avatar-image
<user-avatar
lazy
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:tooltip-text="tooltipText"
tooltip-placement="top"
:size="24"
/>
*/
import { GlTooltip, GlAvatar } from '@gitlab/ui';
import { isObject } from 'lodash';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import UserAvatarImageNew from './user_avatar_image_new.vue';
import UserAvatarImageOld from './user_avatar_image_old.vue';
import { placeholderImage } from '~/lazy_loader';
export default {
name: 'UserAvatarImage',
components: {
UserAvatarImageNew,
UserAvatarImageOld,
GlTooltip,
GlAvatar,
},
mixins: [glFeatureFlagMixin()],
props: {
lazy: {
type: Boolean,
@ -51,8 +51,7 @@ export default {
},
size: {
type: [Number, Object],
required: false,
default: 20,
required: true,
},
tooltipText: {
type: String,
@ -64,22 +63,52 @@ export default {
required: false,
default: 'top',
},
enforceGlAvatar: {
type: Boolean,
required: false,
},
computed: {
// API response sends null when gravatar is disabled and
// we provide an empty string when we use it inside user avatar link.
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
// Only adds the width to the URL if its not a base64 data image
if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
baseSrc += `?width=${this.maximumSize}`;
return baseSrc;
},
maximumSize() {
if (isObject(this.size)) {
return Math.max(...Object.values(this.size));
}
return this.size;
},
resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource;
},
},
};
</script>
<template>
<user-avatar-image-new
v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar"
v-bind="$props"
>
<slot></slot>
</user-avatar-image-new>
<user-avatar-image-old v-else v-bind="$props">
<slot></slot>
</user-avatar-image-old>
<span ref="userAvatar">
<gl-avatar
:class="{
lazy: lazy,
[cssClasses]: true,
}"
:src="resultantSrcAttribute"
:data-src="sanitizedSource"
:size="size"
:alt="imgAlt"
/>
<gl-tooltip
v-if="tooltipText || $scopedSlots.default"
:target="() => $refs.userAvatar"
:placement="tooltipPlacement"
boundary="window"
>
<slot>{{ tooltipText }}</slot>
</gl-tooltip>
</span>
</template>

View File

@ -1,117 +0,0 @@
<script>
/* This is a re-usable vue component for rendering a user avatar that
does not need to link to the user's profile. The image and an optional
tooltip can be configured by props passed to this component.
Sample configuration:
<user-avatar
lazy
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:tooltip-text="tooltipText"
tooltip-placement="top"
/>
*/
import { GlTooltip, GlAvatar } from '@gitlab/ui';
import { isObject } from 'lodash';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
import { placeholderImage } from '~/lazy_loader';
export default {
name: 'UserAvatarImageNew',
components: {
GlTooltip,
GlAvatar,
},
props: {
lazy: {
type: Boolean,
required: false,
default: false,
},
imgSrc: {
type: String,
required: false,
default: defaultAvatarUrl,
},
cssClasses: {
type: String,
required: false,
default: '',
},
imgAlt: {
type: String,
required: false,
default: __('user avatar'),
},
size: {
type: [Number, Object],
required: false,
default: 20,
},
tooltipText: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'top',
},
},
computed: {
// API response sends null when gravatar is disabled and
// we provide an empty string when we use it inside user avatar link.
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
// Only adds the width to the URL if its not a base64 data image
if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
baseSrc += `?width=${this.maximumSize}`;
return baseSrc;
},
maximumSize() {
if (isObject(this.size)) {
return Math.max(...Object.values(this.size));
}
return this.size;
},
resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource;
},
},
};
</script>
<template>
<span ref="userAvatar">
<gl-avatar
:class="{
lazy: lazy,
[cssClasses]: true,
}"
:src="resultantSrcAttribute"
:data-src="sanitizedSource"
:size="size"
:alt="imgAlt"
/>
<gl-tooltip
v-if="
tooltipText ||
$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
"
:target="() => $refs.userAvatar"
:placement="tooltipPlacement"
boundary="window"
>
<slot>{{ tooltipText }}</slot>
</gl-tooltip>
</span>
</template>

View File

@ -1,114 +0,0 @@
<script>
/* This is a re-usable vue component for rendering a user avatar that
does not need to link to the user's profile. The image and an optional
tooltip can be configured by props passed to this component.
Sample configuration:
<user-avatar-image
lazy
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:tooltip-text="tooltipText"
tooltip-placement="top"
/>
*/
import { GlTooltip } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
import { placeholderImage } from '~/lazy_loader';
export default {
name: 'UserAvatarImageOld',
components: {
GlTooltip,
},
props: {
lazy: {
type: Boolean,
required: false,
default: false,
},
imgSrc: {
type: String,
required: false,
default: defaultAvatarUrl,
},
cssClasses: {
type: String,
required: false,
default: '',
},
imgAlt: {
type: String,
required: false,
default: __('user avatar'),
},
size: {
type: Number,
required: false,
default: 20,
},
tooltipText: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'top',
},
},
computed: {
// API response sends null when gravatar is disabled and
// we provide an empty string when we use it inside user avatar link.
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
// Only adds the width to the URL if its not a base64 data image
if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
baseSrc += `?width=${this.size}`;
return baseSrc;
},
resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource;
},
avatarSizeClass() {
return `s${this.size}`;
},
},
};
</script>
<template>
<span>
<img
ref="userAvatarImage"
:class="{
lazy: lazy,
[avatarSizeClass]: true,
[cssClasses]: true,
}"
:src="resultantSrcAttribute"
:width="size"
:height="size"
:alt="imgAlt"
:data-src="sanitizedSource"
class="avatar"
/>
<gl-tooltip
v-if="
tooltipText ||
$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
"
:target="() => $refs.userAvatarImage"
:placement="tooltipPlacement"
boundary="window"
>
<slot>{{ tooltipText }}</slot>
</gl-tooltip>
</span>
</template>

View File

@ -9,7 +9,7 @@
:link-href="userProfileUrl"
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:img-size="20"
:img-size="32"
:tooltip-text="tooltipText"
:tooltip-placement="top"
:username="username"
@ -17,17 +17,18 @@
*/
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import UserAvatarLinkNew from './user_avatar_link_new.vue';
import UserAvatarLinkOld from './user_avatar_link_old.vue';
import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui';
import UserAvatarImage from './user_avatar_image.vue';
export default {
name: 'UserAvatarLink',
name: 'UserAvatarLinkNew',
components: {
UserAvatarLinkNew,
UserAvatarLinkOld,
UserAvatarImage,
GlAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagMixin()],
props: {
lazy: {
type: Boolean,
@ -56,8 +57,7 @@ export default {
},
imgSize: {
type: [Number, Object],
required: false,
default: 20,
required: true,
},
tooltipText: {
type: String,
@ -74,29 +74,43 @@ export default {
required: false,
default: '',
},
enforceGlAvatar: {
type: Boolean,
required: false,
},
computed: {
shouldShowUsername() {
return this.username.length > 0;
},
avatarTooltipText() {
return this.shouldShowUsername ? '' : this.tooltipText;
},
},
};
</script>
<template>
<user-avatar-link-new
v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar"
v-bind="$props"
>
<slot></slot>
<template #avatar-badge>
<slot name="avatar-badge"></slot>
</template>
</user-avatar-link-new>
<gl-avatar-link :href="linkHref" class="user-avatar-link">
<user-avatar-image
:img-src="imgSrc"
:img-alt="imgAlt"
:css-classes="imgCssClasses"
:size="imgSize"
:tooltip-text="avatarTooltipText"
:tooltip-placement="tooltipPlacement"
:lazy="lazy"
>
<slot></slot>
</user-avatar-image>
<user-avatar-link-old v-else v-bind="$props">
<slot></slot>
<template #avatar-badge>
<slot name="avatar-badge"></slot>
</template>
</user-avatar-link-old>
<span
v-if="shouldShowUsername"
v-gl-tooltip
:title="tooltipText"
:tooltip-placement="tooltipPlacement"
class="gl-ml-3"
data-testid="user-avatar-link-username"
>
{{ username }}
</span>
<slot name="avatar-badge"></slot>
</gl-avatar-link>
</template>

View File

@ -1,122 +0,0 @@
<script>
/* This is a re-usable vue component for rendering a user avatar wrapped in
a clickable link (likely to the user's profile). The link, image, and
tooltip can be configured by props passed to this component.
Sample configuration:
<user-avatar-link
:link-href="userProfileUrl"
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:img-size="20"
:tooltip-text="tooltipText"
:tooltip-placement="top"
:username="username"
/>
*/
import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui';
import UserAvatarImage from './user_avatar_image.vue';
export default {
name: 'UserAvatarLinkNew',
components: {
UserAvatarImage,
GlAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
lazy: {
type: Boolean,
required: false,
default: false,
},
linkHref: {
type: String,
required: false,
default: '',
},
imgSrc: {
type: String,
required: false,
default: '',
},
imgAlt: {
type: String,
required: false,
default: '',
},
imgCssClasses: {
type: String,
required: false,
default: '',
},
imgSize: {
type: [Number, Object],
required: false,
default: 20,
},
tooltipText: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'top',
},
username: {
type: String,
required: false,
default: '',
},
enforceGlAvatar: {
type: Boolean,
required: false,
},
},
computed: {
shouldShowUsername() {
return this.username.length > 0;
},
avatarTooltipText() {
return this.shouldShowUsername ? '' : this.tooltipText;
},
},
};
</script>
<template>
<gl-avatar-link :href="linkHref" class="user-avatar-link">
<user-avatar-image
:img-src="imgSrc"
:img-alt="imgAlt"
:css-classes="imgCssClasses"
:size="imgSize"
:tooltip-text="avatarTooltipText"
:tooltip-placement="tooltipPlacement"
:lazy="lazy"
:enforce-gl-avatar="enforceGlAvatar"
>
<slot></slot>
</user-avatar-image>
<span
v-if="shouldShowUsername"
v-gl-tooltip
:title="tooltipText"
:tooltip-placement="tooltipPlacement"
class="gl-ml-3"
data-testid="user-avatar-link-username"
>
{{ username }}
</span>
<slot name="avatar-badge"></slot>
</gl-avatar-link>
</template>

View File

@ -1,117 +0,0 @@
<script>
/* This is a re-usable vue component for rendering a user avatar wrapped in
a clickable link (likely to the user's profile). The link, image, and
tooltip can be configured by props passed to this component.
Sample configuration:
<user-avatar-link
:link-href="userProfileUrl"
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:img-size="20"
:tooltip-text="tooltipText"
:tooltip-placement="top"
:username="username"
/>
*/
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import UserAvatarImage from './user_avatar_image.vue';
export default {
name: 'UserAvatarLinkOld',
components: {
GlLink,
UserAvatarImage,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
lazy: {
type: Boolean,
required: false,
default: false,
},
linkHref: {
type: String,
required: false,
default: '',
},
imgSrc: {
type: String,
required: false,
default: '',
},
imgAlt: {
type: String,
required: false,
default: '',
},
imgCssClasses: {
type: String,
required: false,
default: '',
},
imgSize: {
type: Number,
required: false,
default: 20,
},
tooltipText: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'top',
},
username: {
type: String,
required: false,
default: '',
},
},
computed: {
shouldShowUsername() {
return this.username.length > 0;
},
avatarTooltipText() {
return this.shouldShowUsername ? '' : this.tooltipText;
},
},
};
</script>
<template>
<span>
<gl-link :href="linkHref" class="user-avatar-link">
<user-avatar-image
:img-src="imgSrc"
:img-alt="imgAlt"
:css-classes="imgCssClasses"
:size="imgSize"
:tooltip-text="avatarTooltipText"
:tooltip-placement="tooltipPlacement"
:lazy="lazy"
>
<slot></slot>
</user-avatar-image>
<span
v-if="shouldShowUsername"
v-gl-tooltip
:title="tooltipText"
:tooltip-placement="tooltipPlacement"
data-testid="user-avatar-link-username"
>
{{ username }}
</span>
<slot name="avatar-badge"></slot>
</gl-link>
</span>
</template>

View File

@ -1,6 +1,5 @@
<script>
import { GlButton } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintf, __ } from '~/locale';
import UserAvatarLink from './user_avatar_link.vue';
@ -9,7 +8,6 @@ export default {
UserAvatarLink,
GlButton,
},
mixins: [glFeatureFlagMixin()],
props: {
items: {
type: Array,
@ -22,8 +20,7 @@ export default {
},
imgSize: {
type: [Number, Object],
required: false,
default: 20,
required: true,
},
emptyText: {
type: String,
@ -59,9 +56,6 @@ export default {
return sprintf(__('%{count} more'), { count });
},
imgCssClasses() {
return this.glFeatures.glAvatarForAllUserAvatars ? 'gl-mr-3' : '';
},
},
methods: {
expand() {
@ -85,7 +79,7 @@ export default {
:img-alt="item.name"
:tooltip-text="item.name"
:img-size="imgSize"
:img-css-classes="imgCssClasses"
img-css-classes="gl-mr-3"
/>
<template v-if="hasBreakpoint">
<gl-button v-if="hasHiddenItems" variant="link" @click="expand">

View File

@ -177,7 +177,8 @@ module GroupsHelper
subgroups_and_projects_endpoint: group_children_path(group, format: :json),
shared_projects_endpoint: group_shared_projects_path(group, format: :json),
archived_projects_endpoint: group_children_path(group, format: :json, archived: 'only'),
current_group_visibility: group.visibility
current_group_visibility: group.visibility,
initial_sort: project_list_sort_by
}.merge(subgroups_and_projects_list_app_data(group))
end

View File

@ -19,7 +19,9 @@
%p.gl-text-center= html_escape(_('%{gitlab_experience_text}. Don\'t worry, this information isn\'t shared outside of your self-managed GitLab instance.')) % { gitlab_experience_text: gitlab_experience_text }
= gitlab_ui_form_for(current_user,
url: users_sign_up_welcome_path(glm_tracking_params),
html: { class: 'card gl-w-full! gl-p-5 js-users-signup-welcome', 'aria-live' => 'assertive' }) do |f|
html: { class: 'card gl-w-full! gl-p-5 js-users-signup-welcome',
'aria-live' => 'assertive',
data: { testid: 'welcome-form' } }) do |f|
.devise-errors
= render 'devise/shared/error_messages', resource: current_user
.row

View File

@ -1,8 +0,0 @@
---
name: gl_avatar_for_all_user_avatars
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81437
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353477
milestone: '14.9'
type: development
group: group::foundations
default_enabled: false

View File

@ -0,0 +1,9 @@
---
table_name: project_wiki_repository_states
classes:
- ProjectWikiRepositoryState
feature_categories:
- geo_replication
description: Separate table for project wikis containing Geo verification metadata.
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/99168
milestone: '15.5'

View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
class CreateProjectWikiRepositoryStates < Gitlab::Database::Migration[2.0]
VERIFICATION_STATE_INDEX_NAME = "index_project_wiki_repository_states_on_verification_state"
PENDING_VERIFICATION_INDEX_NAME = "index_project_wiki_repository_states_pending_verification"
FAILED_VERIFICATION_INDEX_NAME = "index_project_wiki_repository_states_failed_verification"
NEEDS_VERIFICATION_INDEX_NAME = "index_project_wiki_repository_states_needs_verification"
enable_lock_retries!
def up
create_table :project_wiki_repository_states, id: false do |t|
t.datetime_with_timezone :verification_started_at
t.datetime_with_timezone :verification_retry_at
t.datetime_with_timezone :verified_at
t.references :project, primary_key: true, default: nil, index: false, foreign_key: { on_delete: :cascade }
t.integer :verification_state, default: 0, limit: 2, null: false
t.integer :verification_retry_count, limit: 2
t.binary :verification_checksum, using: 'verification_checksum::bytea'
t.text :verification_failure, limit: 255
t.index :verification_state,
name: VERIFICATION_STATE_INDEX_NAME
t.index :verified_at,
where: "(verification_state = 0)",
order: { verified_at: 'ASC NULLS FIRST' },
name: PENDING_VERIFICATION_INDEX_NAME
t.index :verification_retry_at,
where: "(verification_state = 3)",
order: { verification_retry_at: 'ASC NULLS FIRST' },
name: FAILED_VERIFICATION_INDEX_NAME
t.index :verification_state,
where: "(verification_state = 0 OR verification_state = 3)",
name: NEEDS_VERIFICATION_INDEX_NAME
end
end
def down
drop_table :project_wiki_repository_states
end
end

View File

@ -0,0 +1 @@
b2492ebefc3738dfe706379ef664d3f28315102acc1c0681ba67e6aae62861d7

View File

@ -20153,6 +20153,18 @@ CREATE SEQUENCE project_topics_id_seq
ALTER SEQUENCE project_topics_id_seq OWNED BY project_topics.id;
CREATE TABLE project_wiki_repository_states (
verification_started_at timestamp with time zone,
verification_retry_at timestamp with time zone,
verified_at timestamp with time zone,
project_id bigint NOT NULL,
verification_state smallint DEFAULT 0 NOT NULL,
verification_retry_count smallint,
verification_checksum bytea,
verification_failure text,
CONSTRAINT check_119f134b68 CHECK ((char_length(verification_failure) <= 255))
);
CREATE TABLE projects (
id integer NOT NULL,
name character varying,
@ -26184,6 +26196,9 @@ ALTER TABLE ONLY project_statistics
ALTER TABLE ONLY project_topics
ADD CONSTRAINT project_topics_pkey PRIMARY KEY (id);
ALTER TABLE ONLY project_wiki_repository_states
ADD CONSTRAINT project_wiki_repository_states_pkey PRIMARY KEY (project_id);
ALTER TABLE ONLY projects
ADD CONSTRAINT projects_pkey PRIMARY KEY (id);
@ -29957,6 +29972,14 @@ CREATE INDEX index_project_topics_on_topic_id ON project_topics USING btree (top
CREATE UNIQUE INDEX index_project_user_callouts_feature ON user_project_callouts USING btree (user_id, feature_name, project_id);
CREATE INDEX index_project_wiki_repository_states_failed_verification ON project_wiki_repository_states USING btree (verification_retry_at NULLS FIRST) WHERE (verification_state = 3);
CREATE INDEX index_project_wiki_repository_states_needs_verification ON project_wiki_repository_states USING btree (verification_state) WHERE ((verification_state = 0) OR (verification_state = 3));
CREATE INDEX index_project_wiki_repository_states_on_verification_state ON project_wiki_repository_states USING btree (verification_state);
CREATE INDEX index_project_wiki_repository_states_pending_verification ON project_wiki_repository_states USING btree (verified_at NULLS FIRST) WHERE (verification_state = 0);
CREATE INDEX index_projects_aimed_for_deletion ON projects USING btree (marked_for_deletion_at) WHERE ((marked_for_deletion_at IS NOT NULL) AND (pending_delete = false));
CREATE INDEX index_projects_api_created_at_id_desc ON projects USING btree (created_at, id DESC);
@ -34240,6 +34263,9 @@ ALTER TABLE ONLY packages_debian_project_distributions
ALTER TABLE ONLY packages_rubygems_metadata
ADD CONSTRAINT fk_rails_95a3f5ce78 FOREIGN KEY (package_id) REFERENCES packages_packages(id) ON DELETE CASCADE;
ALTER TABLE ONLY project_wiki_repository_states
ADD CONSTRAINT fk_rails_9647227ce1 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY packages_pypi_metadata
ADD CONSTRAINT fk_rails_9698717cdd FOREIGN KEY (package_id) REFERENCES packages_packages(id) ON DELETE CASCADE;

View File

@ -247,8 +247,8 @@ The connection settings match those provided by [fog-aws](https://github.com/fog
| `aws_signature_version` | AWS signature version to use. `2` or `4` are valid options. Digital Ocean Spaces and other providers may need `2`. | `4` |
| `enable_signature_v4_streaming` | Set to `true` to enable HTTP chunked transfers with [AWS v4 signatures](https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html). Oracle Cloud S3 needs this to be `false`. | `true` |
| `region` | AWS region. | |
| `host` | S3 compatible host for when not using AWS. For example, `localhost` or `storage.example.com`. HTTPS and port 443 is assumed. | `s3.amazonaws.com` |
| `endpoint` | Can be used when configuring an S3 compatible service such as [MinIO](https://min.io), by entering a URL such as `http://127.0.0.1:9000`. This takes precedence over `host`. | (optional) |
| `host` | DEPRECATED: Use `endpoint` instead. S3 compatible host for when not using AWS. For example, `localhost` or `storage.example.com`. HTTPS and port 443 is assumed. | `s3.amazonaws.com` |
| `endpoint` | Can be used when configuring an S3 compatible service such as [MinIO](https://min.io), by entering a URL such as `http://127.0.0.1:9000`. This takes precedence over `host`. Always use `endpoint` for consolidated form. | (optional) |
| `path_style` | Set to `true` to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Set to `true` for using [MinIO](https://min.io). Leave as `false` for AWS S3. | `false`. |
| `use_iam_profile` | Set to `true` to use IAM profile instead of access keys. | `false` |
| `aws_credentials_refresh_threshold_seconds` | Sets the [automatic refresh threshold](https://github.com/fog/fog-aws#controlling-credential-refresh-time-with-iam-authentication) when using temporary credentials in IAM. | `15` |

View File

@ -287,7 +287,7 @@ Parameters:
| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, `similarity` (1), or `last_activity_at` fields. Default is `created_at` |
| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` |
| `search` | string | no | Return list of authorized projects matching the search criteria |
| `simple` | boolean | no | Return only the ID, URL, name, and path of each project |
| `simple` | boolean | no | Return only limited fields for each project. This is a no-op without authentication where only simple fields are returned. |
| `owned` | boolean | no | Limit by projects owned by the current user |
| `starred` | boolean | no | Limit by projects starred by the current user |
| `with_issues_enabled` | boolean | no | Limit by projects with issues feature enabled. Default is `false` |
@ -370,7 +370,7 @@ Parameters:
| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at` |
| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` |
| `search` | string | no | Return list of authorized projects matching the search criteria |
| `simple` | boolean | no | Return only the ID, URL, name, and path of each project |
| `simple` | boolean | no | Return only limited fields for each project. This is a no-op without authentication where only simple fields are returned. |
| `starred` | boolean | no | Limit by projects starred by the current user |
| `with_issues_enabled` | boolean | no | Limit by projects with issues feature enabled. Default is `false` |
| `with_merge_requests_enabled` | boolean | no | Limit by projects with merge requests feature enabled. Default is `false` |

View File

@ -61,7 +61,7 @@ GET /projects
| `repository_storage` | string | **{dotted-circle}** No | Limit results to projects stored on `repository_storage`. _(administrators only)_ |
| `search_namespaces` | boolean | **{dotted-circle}** No | Include ancestor namespaces when matching search criteria. Default is `false`. |
| `search` | string | **{dotted-circle}** No | Return list of projects matching the search criteria. |
| `simple` | boolean | **{dotted-circle}** No | Return only limited fields for each project. This is a no-op without authentication as then _only_ simple fields are returned. |
| `simple` | boolean | **{dotted-circle}** No | Return only limited fields for each project. This is a no-op without authentication where only simple fields are returned. |
| `sort` | string | **{dotted-circle}** No | Return projects sorted in `asc` or `desc` order. Default is `desc`. |
| `starred` | boolean | **{dotted-circle}** No | Limit by projects starred by the current user. |
| `statistics` | boolean | **{dotted-circle}** No | Include project statistics. Only available to Reporter or higher level role members. |
@ -336,7 +336,7 @@ GET /users/:user_id/projects
| `order_by` | string | **{dotted-circle}** No | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at`. |
| `owned` | boolean | **{dotted-circle}** No | Limit by projects explicitly owned by the current user. |
| `search` | string | **{dotted-circle}** No | Return list of projects matching the search criteria. |
| `simple` | boolean | **{dotted-circle}** No | Return only limited fields for each project. This is a no-op without authentication as then _only_ simple fields are returned. |
| `simple` | boolean | **{dotted-circle}** No | Return only limited fields for each project. This is a no-op without authentication where only simple fields are returned. |
| `sort` | string | **{dotted-circle}** No | Return projects sorted in `asc` or `desc` order. Default is `desc`. |
| `starred` | boolean | **{dotted-circle}** No | Limit by projects starred by the current user. |
| `statistics` | boolean | **{dotted-circle}** No | Include project statistics. Only available to Reporter or higher level role members. |
@ -591,7 +591,7 @@ GET /users/:user_id/starred_projects
| `order_by` | string | **{dotted-circle}** No | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at`. |
| `owned` | boolean | **{dotted-circle}** No | Limit by projects explicitly owned by the current user. |
| `search` | string | **{dotted-circle}** No | Return list of projects matching the search criteria. |
| `simple` | boolean | **{dotted-circle}** No | Return only limited fields for each project. This is a no-op without authentication as then _only_ simple fields are returned. |
| `simple` | boolean | **{dotted-circle}** No | Return only limited fields for each project. This is a no-op without authentication where only simple fields are returned. |
| `sort` | string | **{dotted-circle}** No | Return projects sorted in `asc` or `desc` order. Default is `desc`. |
| `starred` | boolean | **{dotted-circle}** No | Limit by projects starred by the current user. |
| `statistics` | boolean | **{dotted-circle}** No | Include project statistics. Only available to Reporter or higher level role members. |
@ -1476,7 +1476,7 @@ GET /projects/:id/forks
| `order_by` | string | **{dotted-circle}** No | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at`. |
| `owned` | boolean | **{dotted-circle}** No | Limit by projects explicitly owned by the current user. |
| `search` | string | **{dotted-circle}** No | Return list of projects matching the search criteria. |
| `simple` | boolean | **{dotted-circle}** No | Return only limited fields for each project. This is a no-op without authentication as then _only_ simple fields are returned. |
| `simple` | boolean | **{dotted-circle}** No | Return only limited fields for each project. This is a no-op without authentication where only simple fields are returned. |
| `sort` | string | **{dotted-circle}** No | Return projects sorted in `asc` or `desc` order. Default is `desc`. |
| `starred` | boolean | **{dotted-circle}** No | Limit by projects starred by the current user. |
| `statistics` | boolean | **{dotted-circle}** No | Include project statistics. Only available to Reporter or higher level role members. |

View File

@ -282,8 +282,8 @@ Use one of the following methods to determine the value for `DOCKER_AUTH_CONFIG`
configuration JSON manually. Open a terminal and execute the following command:
```shell
# The use of "-n" - prevents encoding a newline in the password.
echo -n "my_username:my_password" | base64
# The use of printf (as opposed to echo) prevents encoding a newline in the password.
printf "my_username:my_password" | openssl base64 -A
# Example output to copy
bXlfdXNlcm5hbWU6bXlfcGFzc3dvcmQ=

View File

@ -7,131 +7,151 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Vault Authentication with GitLab OpenID Connect **(FREE)**
[Vault](https://www.vaultproject.io/) is a secrets management application offered by HashiCorp.
It allows you to store and manage sensitive information such as secret environment variables, encryption keys, and authentication tokens.
Vault offers Identity-based Access, which means Vault users can authenticate through several of their preferred cloud providers.
It allows you to store and manage sensitive information such as secret environment
variables, encryption keys, and authentication tokens.
This document explains how Vault users can authenticate themselves through GitLab by utilizing our OpenID authentication feature.
The following assumes you already have Vault installed and running.
Vault offers Identity-based Access, which means Vault users can authenticate
through several of their preferred cloud providers.
1. **Get the OpenID Connect client ID and secret from GitLab:**
The following content explains how Vault users can authenticate themselves through
GitLab by using our OpenID authentication feature.
First you must create a GitLab application to obtain an application ID and secret for authenticating into Vault.
To do this, sign in to GitLab and follow these steps:
## Prerequisites
1. In the top-right corner, select your avatar.
1. Select **Edit profile**.
1. On the left sidebar, select **Applications**.
1. Fill out the application **Name** and [**Redirect URI**](https://www.vaultproject.io/docs/auth/jwt#redirect-uris).
1. Select the **OpenID** scope.
1. Select **Save application**.
1. Copy client ID and secret, or keep the page open for reference.
1. [Install Vault](https://www.vaultproject.io/docs/install).
1. Run Vault.
![GitLab OAuth provider](img/gitlab_oauth_vault_v12_6.png)
## Get the OpenID Connect client ID and secret from GitLab
1. **Enable OIDC auth on Vault:**
First you must create a GitLab application to obtain an application ID and secret
for authenticating into Vault. To do this, sign in to GitLab and follow these steps:
OpenID Connect is not enabled in Vault by default. This needs to be enabled in the terminal.
1. In the top-right corner, select your avatar.
1. Select **Edit profile**.
1. On the left sidebar, select **Applications**.
1. Fill out the application **Name** and [**Redirect URI**](https://www.vaultproject.io/docs/auth/jwt#redirect-uris).
1. Select the **OpenID** scope.
1. Select **Save application**.
1. Copy the **Client ID** and **Client Secret**, or keep the page open for reference.
Open a terminal session and run the following command to enable the OpenID Connect authentication provider in Vault:
![GitLab OAuth provider](img/gitlab_oauth_vault_v12_6.png)
```shell
vault auth enable oidc
```
## Enable OpenID Connect on Vault
You should see the following output in the terminal:
OpenID Connect (OIDC) is not enabled in Vault by default.
```plaintext
Success! Enabled oidc auth method at: oidc/
```
To enable the OIDC authentication provider in Vault, open a terminal session
and run the following command:
1. **Write the OIDC configuration:**
```shell
vault auth enable oidc
```
Next, Vault needs to be given the application ID and secret generated by GitLab.
You should see the following output in the terminal:
In the terminal session, run the following command to give Vault access to the GitLab application you've just created with an OpenID scope. This allows Vault to authenticate through GitLab.
```plaintext
Success! Enabled oidc auth method at: oidc/
```
Replace `your_application_id` and `your_secret` in the example below with the application ID and secret generated for your app:
## Write the OIDC configuration
```shell
$ vault write auth/oidc/config \
oidc_discovery_url="https://gitlab.com" \
oidc_client_id="your_application_id" \
oidc_client_secret="your_secret" \
default_role="demo" \
bound_issuer="localhost"
```
To give Vault the application ID and secret generated by GitLab and allow
Vault to authenticate through GitLab, run the following command in the terminal:
You should see the following output in the terminal:
```shell
$ vault write auth/oidc/config \
oidc_discovery_url="https://gitlab.com" \
oidc_client_id="<your_application_id>" \
oidc_client_secret="<your_secret>" \
default_role="demo" \
bound_issuer="localhost"
```
```shell
Success! Data written to: auth/oidc/config
```
Replace `<your_application_id>` and `<your_secret>` with the application ID
and secret generated for your app.
1. **Write the OIDC Role Configuration:**
You should see the following output in the terminal:
Now that Vault has a GitLab application ID and secret, it needs to know the [**Redirect URIs**](https://www.vaultproject.io/docs/auth/jwt#redirect-uris) and scopes given to GitLab during the application creation process. The redirect URIs need to match where your Vault instance is running. The `oidc_scopes` field needs to include the `openid`. Similarly to the previous step, replace `your_application_id` with the generated application ID from GitLab:
```shell
Success! Data written to: auth/oidc/config
```
This configuration is saved under the name of the role you are creating. In this case, we are creating a `demo` role. Later, we show how you can access this role through the Vault CLI.
## Write the OIDC role configuration
WARNING:
If you're using a public GitLab instance (GitLab.com or any other instance publicly
accessible), it's paramount to specify the `bound_claims` to allow access only to
members of your group/project. Otherwise, anyone with a public account can access
your Vault instance.
You must tell Vault the [**Redirect URIs**](https://www.vaultproject.io/docs/auth/jwt#redirect-uris)
and scopes given to GitLab when you created the application.
```shell
vault write auth/oidc/role/demo -<<EOF
{
"user_claim": "sub",
"allowed_redirect_uris": "your_vault_instance_redirect_uris",
"bound_audiences": "your_application_id",
"oidc_scopes": "openid",
"role_type": "oidc",
"policies": "demo",
"ttl": "1h",
"bound_claims": { "groups": ["yourGroup/yourSubgrup"] }
}
EOF
```
Run the following command in the terminal:
1. **Sign in to Vault:**
```shell
vault write auth/oidc/role/demo -<<EOF
{
"user_claim": "sub",
"allowed_redirect_uris": "<your_vault_instance_redirect_uris>",
"bound_audiences": "<your_application_id>",
"oidc_scopes": "<openid>",
"role_type": "oidc",
"policies": "demo",
"ttl": "1h",
"bound_claims": { "groups": ["<yourGroup/yourSubgrup>"] }
}
EOF
```
1. Go to your Vault UI (example: [http://127.0.0.1:8200/ui/vault/auth?with=oidc](http://127.0.0.1:8200/ui/vault/auth?with=oidc)).
1. If the `OIDC` method is not currently selected, open the dropdown and select it.
1. Select **Sign in With GitLab**, which opens a modal window:
Replace:
![Sign into Vault with GitLab](img/sign_into_vault_with_gitlab_v12_6.png)
- `<your_vault_instance_redirect_uris>` with redirect URIs that match where your
Vault instance is running.
- `<your_application_id>` with the application ID generated for your app.
1. Select **Authorize** to allow Vault to sign in through GitLab. This redirects you back to your Vault UI as a signed-in user.
The `oidc_scopes` field must include `openid`.
![Authorize Vault to connect with GitLab](img/authorize_vault_with_gitlab_v12_6.png)
This configuration is saved under the name of the role you are creating. In this
example, we are creating a `demo` role.
1. **Sign in using the Vault CLI** (optional):
WARNING:
If you're using a public GitLab instance, such as GitLab.com, you must specify
the `bound_claims` to allow access only to members of your group or project.
Otherwise, anyone with a public account can access your Vault instance.
Vault also allows you to sign in via their CLI.
## Sign in to Vault
After writing the same configurations from above, you can run the command below in your terminal to sign in with the role configuration created in step 4 above:
1. Go to your Vault UI. For example: [http://127.0.0.1:8200/ui/vault/auth?with=oidc](http://127.0.0.1:8200/ui/vault/auth?with=oidc).
1. If the `OIDC` method is not selected, open the dropdown list and select it.
1. Select **Sign in With GitLab**, which opens a modal window:
![Sign into Vault with GitLab](img/sign_into_vault_with_gitlab_v12_6.png)
1. To allow Vault to sign in through GitLab, select **Authorize**. This redirects you back to your Vault UI as a signed-in user.
![Authorize Vault to connect with GitLab](img/authorize_vault_with_gitlab_v12_6.png)
## Sign in using the Vault CLI (optional)
You can also sign into Vault using the [Vault CLI](https://www.vaultproject.io/docs/commands).
1. To sign in with the role configuration you created in the previous example,
run the following command in your terminal:
```shell
vault login -method=oidc port=8250 role=demo
```
Here's a short explanation of what this command does:
This command sets:
1. In the **Write the OIDC Role Configuration** (step 4), we created a role called
`demo`. We set `role=demo` so Vault knows which configuration we'd like to
sign in with.
1. To set Vault to use the `OIDC` sign-in method, we set `-method=oidc`.
1. To set the port that GitLab should redirect to, we set `port=8250` or
another port number that matches the port given to GitLab when listing
[Redirect URIs](https://www.vaultproject.io/docs/auth/jwt#redirect-uris).
- `role=demo` so Vault knows which configuration we'd like to sign in with.
- `-method=oidc` to set Vault to use the `OIDC` sign-in method.
- `port=8250` to set the port that GitLab should redirect to. This port
number must match the port given to GitLab when listing
[Redirect URIs](https://www.vaultproject.io/docs/auth/jwt#redirect-uris).
After running the command, it presents a link in the terminal.
Select the link in the terminal and a browser tab opens that confirms you're signed into Vault via OIDC:
After running this command, you should see a link in the terminal.
1. Open this link in a web browser:
![Signed into Vault via OIDC](img/signed_into_vault_via_oidc_v12_6.png)
The terminal outputs:
You should see in the terminal:
```plaintext
Success! You are now authenticated. The token information displayed below

View File

@ -392,6 +392,8 @@ The following table, while not exhaustive, shows some examples of the supported
upgrade paths.
Additional steps between the mentioned versions are possible. We list the minimally necessary steps only.
For a dynamic view of examples of supported upgrade paths, try the [Upgrade Path tool](https://gitlab-com.gitlab.io/support/toolbox/upgrade-path/). The Upgrade Path tool is maintained by the [GitLab Support team](https://about.gitlab.com/handbook/support/#about-the-support-team). Share feedback and help improve the tool by raising an issue or MR in the [upgrade-path project](https://gitlab.com/gitlab-com/support/toolbox/upgrade-path).
| Target version | Your version | Supported upgrade path | Note |
| -------------- | ------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `15.1.0` | `14.6.2` | `14.6.2` -> `14.9.5` -> `14.10.5` -> `15.0.2` -> `15.1.0` | Three intermediate versions are required: `14.9` and `14.10`, `15.0`, then `15.1.0`. |

View File

@ -180,8 +180,9 @@ Prerequisites:
To promote a project milestone:
1. On the top bar, select **Main menu > Projects** and find your project.
1. On the left sidebar, select **Issues > Milestones**.
1. Either:
- Select **Promote to Group Milestone** (**{level-up}**).
- Select **Promote to Group Milestone** (**{level-up}**) next to the milestone you want to promote.
- Select the milestone title, and then select **Promote**.
1. Select **Promote Milestone**.

View File

@ -118,7 +118,8 @@ threads. Some quick actions might not be available to all subscription tiers.
| `/unapprove` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Unapprove the merge request. ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/8103) in GitLab 14.3 |
| `/unassign @user1 @user2` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Remove specific assignees. |
| `/unassign` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Remove all assignees. |
| `/unassign_reviewer @user1 @user2` or `/remove_reviewer @user1 @user2` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Remove specific reviewers. |
| `/unassign_reviewer @user1 @user2` or `/remove_reviewer @user1 @user2` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Remove specific reviewers.
| `/unassign_reviewer me` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Remove yourself as a reviewer. |
| `/unassign_reviewer` or `/remove_reviewer` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Remove all reviewers. |
| `/unlabel ~label1 ~label2` or `/remove_label ~label1 ~label2` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Remove specified labels. |
| `/unlabel` or `/remove_label` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Remove all labels. |

View File

@ -56,3 +56,5 @@ module Banzai
end
end
end
Banzai::Filter::MarkdownEngines::CommonMark.prepend_mod

View File

@ -449,6 +449,7 @@ projects: :gitlab_main
projects_sync_events: :gitlab_main
project_statistics: :gitlab_main
project_topics: :gitlab_main
project_wiki_repository_states: :gitlab_main
prometheus_alert_events: :gitlab_main
prometheus_alerts: :gitlab_main
prometheus_metrics: :gitlab_main

View File

@ -55,7 +55,6 @@ module Gitlab
push_frontend_feature_flag(:security_auto_fix)
push_frontend_feature_flag(:new_header_search)
push_frontend_feature_flag(:source_editor_toolbar)
push_frontend_feature_flag(:gl_avatar_for_all_user_avatars)
push_frontend_feature_flag(:gl_listbox_for_sort_dropdowns)
end

View File

@ -9,7 +9,7 @@ module QA
def self.included(base)
super
base.view 'app/views/shared/groups/_search_form.html.haml' do
base.view 'app/assets/javascripts/groups/components/overview_tabs.vue' do
element :groups_filter_field
end

View File

@ -23,7 +23,6 @@ RSpec.describe 'Project issue boards', :js do
project.add_maintainer(user2)
sign_in(user)
stub_feature_flags(gl_avatar_for_all_user_avatars: false)
set_cookie('sidebar_collapsed', 'true')
end

View File

@ -10,7 +10,6 @@ RSpec.describe 'User scrolls to deep-linked note' do
context 'on issue page', :js do
it 'on comment' do
stub_feature_flags(gl_avatar_for_all_user_avatars: false)
visit project_issue_path(project, issue, anchor: "note_#{comment_1.id}")
wait_for_requests

View File

@ -25,7 +25,6 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do
before do
project.add_maintainer(user)
sign_in user
stub_feature_flags(gl_avatar_for_all_user_avatars: false)
set_cookie('sidebar_collapsed', 'true')
end

View File

@ -24,7 +24,6 @@ RSpec.describe 'User sorts projects and order persists' do
end
it "is set on the group_canonical_path" do
stub_feature_flags(group_overview_tabs_vue: false)
visit(group_canonical_path(group))
within '[data-testid=group_sort_by_dropdown]' do
@ -33,7 +32,6 @@ RSpec.describe 'User sorts projects and order persists' do
end
it "is set on the details_group_path" do
stub_feature_flags(group_overview_tabs_vue: false)
visit(details_group_path(group))
within '[data-testid=group_sort_by_dropdown]' do
@ -42,7 +40,7 @@ RSpec.describe 'User sorts projects and order persists' do
end
end
context "from explore projects" do
context "from explore projects", :js do
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
sign_in(user)
@ -51,10 +49,10 @@ RSpec.describe 'User sorts projects and order persists' do
first(:link, 'Updated date').click
end
it_behaves_like "sort order persists across all views", 'Updated date', 'Updated date'
it_behaves_like "sort order persists across all views", 'Updated date', 'Updated'
end
context 'from dashboard projects' do
context 'from dashboard projects', :js do
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
sign_in(user)
@ -68,31 +66,29 @@ RSpec.describe 'User sorts projects and order persists' do
context 'from group homepage', :js do
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
stub_feature_flags(group_overview_tabs_vue: false)
sign_in(user)
visit(group_canonical_path(group))
within '[data-testid=group_sort_by_dropdown]' do
find('button.gl-dropdown-toggle').click
first(:button, 'Last created').click
first(:button, 'Created').click
wait_for_requests
end
end
it_behaves_like "sort order persists across all views", "Created date", "Last created"
it_behaves_like "sort order persists across all views", "Created date", "Created"
end
context 'from group details', :js do
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
stub_feature_flags(group_overview_tabs_vue: false)
sign_in(user)
visit(details_group_path(group))
within '[data-testid=group_sort_by_dropdown]' do
find('button.gl-dropdown-toggle').click
first(:button, 'Most stars').click
first(:button, 'Stars').click
wait_for_requests
end
end
it_behaves_like "sort order persists across all views", "Stars", "Most stars"
it_behaves_like "sort order persists across all views", "Stars", "Stars"
end
end

View File

@ -82,7 +82,7 @@ describe('diffs/components/commit_item', () => {
const imgElement = avatarElement.find('img');
expect(avatarElement.attributes('href')).toBe(commit.author.web_url);
expect(imgElement.classes()).toContain('s32');
expect(imgElement.classes()).toContain('gl-avatar-s32');
expect(imgElement.attributes('alt')).toBe(commit.author.name);
expect(imgElement.attributes('src')).toBe(commit.author.avatar_url);
});

View File

@ -440,6 +440,10 @@ describe('AppComponent', () => {
expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', expect.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', expect.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith(
'fetchFilteredAndSortedGroups',
expect.any(Function),
);
});
it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', async () => {
@ -468,6 +472,46 @@ describe('AppComponent', () => {
expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', expect.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', expect.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith(
'fetchFilteredAndSortedGroups',
expect.any(Function),
);
});
});
describe('when `fetchFilteredAndSortedGroups` event is emitted', () => {
const search = 'Foo bar';
const sort = 'created_asc';
const emitFetchFilteredAndSortedGroups = () => {
eventHub.$emit('fetchFilteredAndSortedGroups', {
filterGroupsBy: search,
sortBy: sort,
});
};
let setPaginationInfoSpy;
beforeEach(() => {
setPaginationInfoSpy = jest.spyOn(GroupsStore.prototype, 'setPaginationInfo');
createShallowComponent();
});
it('renders loading icon', async () => {
emitFetchFilteredAndSortedGroups();
await nextTick();
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('calls API with expected params', () => {
emitFetchFilteredAndSortedGroups();
expect(getGroupsSpy).toHaveBeenCalledWith(undefined, undefined, search, sort, undefined);
});
it('updates pagination', () => {
emitFetchFilteredAndSortedGroups();
expect(setPaginationInfoSpy).toHaveBeenCalled();
});
});

View File

@ -1,4 +1,4 @@
import { GlTab } from '@gitlab/ui';
import { GlSorting, GlSortingItem, GlTab } from '@gitlab/ui';
import { nextTick } from 'vue';
import { createLocalVue } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
@ -9,25 +9,38 @@ import GroupFolderComponent from '~/groups/components/group_folder.vue';
import GroupsStore from '~/groups/store/groups_store';
import GroupsService from '~/groups/service/groups_service';
import { createRouter } from '~/groups/init_overview_tabs';
import eventHub from '~/groups/event_hub';
import {
ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
ACTIVE_TAB_SHARED,
ACTIVE_TAB_ARCHIVED,
OVERVIEW_TABS_SORTING_ITEMS,
} from '~/groups/constants';
import axios from '~/lib/utils/axios_utils';
const localVue = createLocalVue();
localVue.component('GroupFolder', GroupFolderComponent);
const router = createRouter();
const [SORTING_ITEM_NAME, , SORTING_ITEM_UPDATED] = OVERVIEW_TABS_SORTING_ITEMS;
describe('OverviewTabs', () => {
let wrapper;
let axiosMock;
const endpoints = {
subgroups_and_projects: '/groups/foobar/-/children.json',
shared: '/groups/foobar/-/shared_projects.json',
archived: '/groups/foobar/-/children.json?archived=only',
const defaultProvide = {
endpoints: {
subgroups_and_projects: '/groups/foobar/-/children.json',
shared: '/groups/foobar/-/shared_projects.json',
archived: '/groups/foobar/-/children.json?archived=only',
},
newSubgroupPath: '/groups/new',
newProjectPath: 'projects/new',
newSubgroupIllustration: '',
newProjectIllustration: '',
emptySubgroupIllustration: '',
canCreateSubgroups: false,
canCreateProjects: false,
initialSort: 'name_asc',
};
const routerMock = {
@ -36,18 +49,13 @@ describe('OverviewTabs', () => {
const createComponent = async ({
route = { name: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, params: { group: 'foo/bar/baz' } },
provide = {},
} = {}) => {
wrapper = mountExtended(OverviewTabs, {
router,
provide: {
endpoints,
newSubgroupPath: '/groups/new',
newProjectPath: 'projects/new',
newSubgroupIllustration: '',
newProjectIllustration: '',
emptySubgroupIllustration: '',
canCreateSubgroups: false,
canCreateProjects: false,
...defaultProvide,
...provide,
},
localVue,
mocks: { $route: route, $router: routerMock },
@ -81,7 +89,7 @@ describe('OverviewTabs', () => {
expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({
action: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
store: new GroupsStore({ showSchemaMarkup: true }),
service: new GroupsService(endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]),
service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]),
hideProjects: false,
renderEmptyState: true,
});
@ -102,7 +110,7 @@ describe('OverviewTabs', () => {
expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({
action: ACTIVE_TAB_SHARED,
store: new GroupsStore(),
service: new GroupsService(endpoints[ACTIVE_TAB_SHARED]),
service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SHARED]),
hideProjects: false,
renderEmptyState: false,
});
@ -125,7 +133,7 @@ describe('OverviewTabs', () => {
expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({
action: ACTIVE_TAB_ARCHIVED,
store: new GroupsStore(),
service: new GroupsService(endpoints[ACTIVE_TAB_ARCHIVED]),
service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_ARCHIVED]),
hideProjects: false,
renderEmptyState: false,
});
@ -197,4 +205,109 @@ describe('OverviewTabs', () => {
expect(routerMock.push).toHaveBeenCalledWith(expectedRoute);
});
});
describe('searching and sorting', () => {
const setup = async () => {
jest.spyOn(eventHub, '$emit');
await createComponent();
// Click through tabs so they are all loaded
await findTab(OverviewTabs.i18n[ACTIVE_TAB_SHARED]).trigger('click');
await findTab(OverviewTabs.i18n[ACTIVE_TAB_ARCHIVED]).trigger('click');
await findTab(OverviewTabs.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]).trigger('click');
};
const sharedAssertions = ({ search, sort }) => {
it('sets `lazy` prop to `true` for all of the non-active tabs so they are reloaded after sort or search is applied', () => {
expect(findTabPanels().at(0).vm.$attrs.lazy).toBe(false);
expect(findTabPanels().at(1).vm.$attrs.lazy).toBe(true);
expect(findTabPanels().at(2).vm.$attrs.lazy).toBe(true);
});
it('emits `fetchFilteredAndSortedGroups` event from `eventHub`', () => {
expect(eventHub.$emit).toHaveBeenCalledWith(
`${ACTIVE_TAB_SUBGROUPS_AND_PROJECTS}fetchFilteredAndSortedGroups`,
{
filterGroupsBy: search,
sortBy: sort,
},
);
});
};
describe('when search is typed in', () => {
const search = 'Foo bar';
beforeEach(async () => {
await setup();
await wrapper.findByPlaceholderText(OverviewTabs.i18n.searchPlaceholder).setValue(search);
});
it('updates query string with `filter` key', () => {
expect(routerMock.push).toHaveBeenCalledWith({ query: { filter: search } });
});
sharedAssertions({ search, sort: defaultProvide.initialSort });
});
describe('when sort is changed', () => {
beforeEach(async () => {
await setup();
wrapper.findAllComponents(GlSortingItem).at(2).vm.$emit('click');
await nextTick();
});
it('updates query string with `sort` key', () => {
expect(routerMock.push).toHaveBeenCalledWith({
query: { sort: SORTING_ITEM_UPDATED.asc },
});
});
sharedAssertions({ search: '', sort: SORTING_ITEM_UPDATED.asc });
});
describe('when sort direction is changed', () => {
beforeEach(async () => {
await setup();
await wrapper
.findByRole('button', { name: 'Sorting Direction: Ascending' })
.trigger('click');
});
it('updates query string with `sort` key', () => {
expect(routerMock.push).toHaveBeenCalledWith({
query: { sort: SORTING_ITEM_NAME.desc },
});
});
sharedAssertions({ search: '', sort: SORTING_ITEM_NAME.desc });
});
describe('when `filter` and `sort` query strings are set', () => {
beforeEach(async () => {
await createComponent({
route: {
name: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
params: { group: 'foo/bar/baz' },
query: { filter: 'Foo bar', sort: SORTING_ITEM_UPDATED.desc },
},
});
});
it('sets value of search input', () => {
expect(
wrapper.findByPlaceholderText(OverviewTabs.i18n.searchPlaceholder).element.value,
).toBe('Foo bar');
});
it('sets sort dropdown', () => {
expect(wrapper.findComponent(GlSorting).props()).toMatchObject({
text: SORTING_ITEM_UPDATED.label,
isAscending: false,
});
expect(wrapper.findAllComponents(GlSortingItem).at(2).vm.$attrs.active).toBe(true);
});
});
});
});

View File

@ -7,7 +7,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
<user-avatar-link-stub
class="gl-my-2 gl-mr-4"
imgalt=""
imgcssclasses="gl-mr-0!"
imgcssclasses=""
imgsize="32"
imgsrc="https://test.com"
linkhref="/test"

View File

@ -1,134 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { GlAvatar, GlTooltip } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { placeholderImage } from '~/lazy_loader';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image_new.vue';
jest.mock('images/no_avatar.png', () => 'default-avatar-url');
const PROVIDED_PROPS = {
size: 32,
imgSrc: 'myavatarurl.com',
imgAlt: 'mydisplayname',
cssClasses: 'myextraavatarclass',
tooltipText: 'tooltip text',
tooltipPlacement: 'bottom',
};
describe('User Avatar Image Component', () => {
let wrapper;
const findAvatar = () => wrapper.findComponent(GlAvatar);
afterEach(() => {
wrapper.destroy();
});
describe('Initialization', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
},
});
});
it('should render `GlAvatar` and provide correct properties to it', () => {
expect(findAvatar().attributes('data-src')).toBe(
`${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
);
expect(findAvatar().props()).toMatchObject({
src: `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
alt: PROVIDED_PROPS.imgAlt,
size: PROVIDED_PROPS.size,
});
});
it('should add correct CSS classes', () => {
const classes = wrapper.findComponent(GlAvatar).classes();
expect(classes).toContain(PROVIDED_PROPS.cssClasses);
expect(classes).not.toContain('lazy');
});
});
describe('Initialization when lazy', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
lazy: true,
},
});
});
it('should add lazy attributes', () => {
expect(findAvatar().classes()).toContain('lazy');
expect(findAvatar().attributes()).toMatchObject({
src: placeholderImage,
'data-src': `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
});
});
it('should use maximum number when size is provided as an object', () => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
size: { default: 16, md: 64, lg: 24 },
lazy: true,
},
});
expect(findAvatar().attributes('data-src')).toBe(`${PROVIDED_PROPS.imgSrc}?width=${64}`);
});
});
describe('Initialization without src', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
imgSrc: null,
},
});
});
it('should have default avatar image', () => {
expect(findAvatar().props('src')).toBe(`${defaultAvatarUrl}?width=${PROVIDED_PROPS.size}`);
});
});
describe('Dynamic tooltip content', () => {
const slots = {
default: ['Action!'],
};
describe('when `tooltipText` is provided and no default slot', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: { ...PROVIDED_PROPS },
});
});
it('renders the tooltip with `tooltipText` as content', () => {
expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText);
});
});
describe('when `tooltipText` and default slot is provided', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: { ...PROVIDED_PROPS },
slots,
});
});
it('does not render `tooltipText` inside the tooltip', () => {
expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText);
});
it('renders the content provided via default slot', () => {
expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]);
});
});
});
});

View File

@ -1,127 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { GlTooltip } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { placeholderImage } from '~/lazy_loader';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image_old.vue';
jest.mock('images/no_avatar.png', () => 'default-avatar-url');
const PROVIDED_PROPS = {
size: 32,
imgSrc: 'myavatarurl.com',
imgAlt: 'mydisplayname',
cssClasses: 'myextraavatarclass',
tooltipText: 'tooltip text',
tooltipPlacement: 'bottom',
};
const DEFAULT_PROPS = {
size: 20,
};
describe('User Avatar Image Component', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
});
describe('Initialization', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
},
});
});
it('should have <img> as a child element', () => {
const imageElement = wrapper.find('img');
expect(imageElement.exists()).toBe(true);
expect(imageElement.attributes('src')).toBe(
`${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
);
expect(imageElement.attributes('data-src')).toBe(
`${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
);
expect(imageElement.attributes('alt')).toBe(PROVIDED_PROPS.imgAlt);
});
it('should properly render img css', () => {
const classes = wrapper.find('img').classes();
expect(classes).toEqual(['avatar', 's32', PROVIDED_PROPS.cssClasses]);
expect(classes).not.toContain('lazy');
});
});
describe('Initialization when lazy', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
lazy: true,
},
});
});
it('should add lazy attributes', () => {
const imageElement = wrapper.find('img');
expect(imageElement.classes()).toContain('lazy');
expect(imageElement.attributes('src')).toBe(placeholderImage);
expect(imageElement.attributes('data-src')).toBe(
`${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
);
});
});
describe('Initialization without src', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage);
});
it('should have default avatar image', () => {
const imageElement = wrapper.find('img');
expect(imageElement.attributes('src')).toBe(
`${defaultAvatarUrl}?width=${DEFAULT_PROPS.size}`,
);
});
});
describe('Dynamic tooltip content', () => {
const slots = {
default: ['Action!'],
};
describe('when `tooltipText` is provided and no default slot', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: { ...PROVIDED_PROPS },
});
});
it('renders the tooltip with `tooltipText` as content', () => {
expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText);
});
});
describe('when `tooltipText` and default slot is provided', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: { ...PROVIDED_PROPS },
slots,
});
});
it('does not render `tooltipText` inside the tooltip', () => {
expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText);
});
it('renders the content provided via default slot', () => {
expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]);
});
});
});
});

View File

@ -1,7 +1,10 @@
import { shallowMount } from '@vue/test-utils';
import { GlAvatar, GlTooltip } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { placeholderImage } from '~/lazy_loader';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import UserAvatarImageNew from '~/vue_shared/components/user_avatar/user_avatar_image_new.vue';
import UserAvatarImageOld from '~/vue_shared/components/user_avatar/user_avatar_image_old.vue';
jest.mock('images/no_avatar.png', () => 'default-avatar-url');
const PROVIDED_PROPS = {
size: 32,
@ -15,37 +18,117 @@ const PROVIDED_PROPS = {
describe('User Avatar Image Component', () => {
let wrapper;
const createWrapper = (props = {}, { glAvatarForAllUserAvatars } = {}) => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
...props,
},
provide: {
glFeatures: {
glAvatarForAllUserAvatars,
},
},
});
};
const findAvatar = () => wrapper.findComponent(GlAvatar);
afterEach(() => {
wrapper.destroy();
});
describe.each([
[false, true, true],
[true, false, true],
[true, true, true],
[false, false, false],
])(
'when glAvatarForAllUserAvatars=%s and enforceGlAvatar=%s',
(glAvatarForAllUserAvatars, enforceGlAvatar, isUsingNewVersion) => {
it(`will render ${isUsingNewVersion ? 'new' : 'old'} version`, () => {
createWrapper({ enforceGlAvatar }, { glAvatarForAllUserAvatars });
expect(wrapper.findComponent(UserAvatarImageNew).exists()).toBe(isUsingNewVersion);
expect(wrapper.findComponent(UserAvatarImageOld).exists()).toBe(!isUsingNewVersion);
describe('Initialization', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
},
});
},
);
});
it('should render `GlAvatar` and provide correct properties to it', () => {
expect(findAvatar().attributes('data-src')).toBe(
`${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
);
expect(findAvatar().props()).toMatchObject({
src: `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
alt: PROVIDED_PROPS.imgAlt,
size: PROVIDED_PROPS.size,
});
});
it('should add correct CSS classes', () => {
const classes = wrapper.findComponent(GlAvatar).classes();
expect(classes).toContain(PROVIDED_PROPS.cssClasses);
expect(classes).not.toContain('lazy');
});
});
describe('Initialization when lazy', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
lazy: true,
},
});
});
it('should add lazy attributes', () => {
expect(findAvatar().classes()).toContain('lazy');
expect(findAvatar().attributes()).toMatchObject({
src: placeholderImage,
'data-src': `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`,
});
});
it('should use maximum number when size is provided as an object', () => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
size: { default: 16, md: 64, lg: 24 },
lazy: true,
},
});
expect(findAvatar().attributes('data-src')).toBe(`${PROVIDED_PROPS.imgSrc}?width=${64}`);
});
});
describe('Initialization without src', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: {
...PROVIDED_PROPS,
imgSrc: null,
},
});
});
it('should have default avatar image', () => {
expect(findAvatar().props('src')).toBe(`${defaultAvatarUrl}?width=${PROVIDED_PROPS.size}`);
});
});
describe('Dynamic tooltip content', () => {
const slots = {
default: ['Action!'],
};
describe('when `tooltipText` is provided and no default slot', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: { ...PROVIDED_PROPS },
});
});
it('renders the tooltip with `tooltipText` as content', () => {
expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText);
});
});
describe('when `tooltipText` and default slot is provided', () => {
beforeEach(() => {
wrapper = shallowMount(UserAvatarImage, {
propsData: { ...PROVIDED_PROPS },
slots,
});
});
it('does not render `tooltipText` inside the tooltip', () => {
expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText);
});
it('renders the content provided via default slot', () => {
expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]);
});
});
});
});

View File

@ -1,103 +0,0 @@
import { GlAvatarLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'spec/test_constants';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link_new.vue';
describe('User Avatar Link Component', () => {
let wrapper;
const findUserName = () => wrapper.findByTestId('user-avatar-link-username');
const defaultProps = {
linkHref: `${TEST_HOST}/myavatarurl.com`,
imgSize: 32,
imgSrc: `${TEST_HOST}/myavatarurl.com`,
imgAlt: 'mydisplayname',
imgCssClasses: 'myextraavatarclass',
tooltipText: 'tooltip text',
tooltipPlacement: 'bottom',
username: 'username',
};
const createWrapper = (props, slots) => {
wrapper = shallowMountExtended(UserAvatarLink, {
propsData: {
...defaultProps,
...props,
...slots,
},
});
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
it('should render GlLink with correct props', () => {
const link = wrapper.findComponent(GlAvatarLink);
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe(defaultProps.linkHref);
});
it('should render UserAvatarImage and provide correct props to it', () => {
expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true);
expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({
cssClasses: defaultProps.imgCssClasses,
imgAlt: defaultProps.imgAlt,
imgSrc: defaultProps.imgSrc,
lazy: false,
size: defaultProps.imgSize,
tooltipPlacement: defaultProps.tooltipPlacement,
tooltipText: '',
enforceGlAvatar: false,
});
});
describe('when username provided', () => {
beforeEach(() => {
createWrapper({ username: defaultProps.username });
});
it('should render provided username', () => {
expect(findUserName().text()).toBe(defaultProps.username);
});
it('should provide the tooltip data for the username', () => {
expect(findUserName().attributes()).toEqual(
expect.objectContaining({
title: defaultProps.tooltipText,
'tooltip-placement': defaultProps.tooltipPlacement,
}),
);
});
});
describe('when username is NOT provided', () => {
beforeEach(() => {
createWrapper({ username: '' });
});
it('should NOT render username', () => {
expect(findUserName().exists()).toBe(false);
});
});
describe('avatar-badge slot', () => {
const badge = '<span>User badge</span>';
beforeEach(() => {
createWrapper(defaultProps, {
'avatar-badge': badge,
});
});
it('should render provided `avatar-badge` slot content', () => {
expect(wrapper.html()).toContain(badge);
});
});
});

View File

@ -1,103 +0,0 @@
import { GlLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'spec/test_constants';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link_old.vue';
describe('User Avatar Link Component', () => {
let wrapper;
const findUserName = () => wrapper.find('[data-testid="user-avatar-link-username"]');
const defaultProps = {
linkHref: `${TEST_HOST}/myavatarurl.com`,
imgSize: 32,
imgSrc: `${TEST_HOST}/myavatarurl.com`,
imgAlt: 'mydisplayname',
imgCssClasses: 'myextraavatarclass',
tooltipText: 'tooltip text',
tooltipPlacement: 'bottom',
username: 'username',
};
const createWrapper = (props, slots) => {
wrapper = shallowMountExtended(UserAvatarLink, {
propsData: {
...defaultProps,
...props,
...slots,
},
});
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
it('should render GlLink with correct props', () => {
const link = wrapper.findComponent(GlLink);
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe(defaultProps.linkHref);
});
it('should render UserAvatarImage and povide correct props to it', () => {
expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true);
expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({
cssClasses: defaultProps.imgCssClasses,
imgAlt: defaultProps.imgAlt,
imgSrc: defaultProps.imgSrc,
lazy: false,
size: defaultProps.imgSize,
tooltipPlacement: defaultProps.tooltipPlacement,
tooltipText: '',
enforceGlAvatar: false,
});
});
describe('when username provided', () => {
beforeEach(() => {
createWrapper({ username: defaultProps.username });
});
it('should render provided username', () => {
expect(findUserName().text()).toBe(defaultProps.username);
});
it('should provide the tooltip data for the username', () => {
expect(findUserName().attributes()).toEqual(
expect.objectContaining({
title: defaultProps.tooltipText,
'tooltip-placement': defaultProps.tooltipPlacement,
}),
);
});
});
describe('when username is NOT provided', () => {
beforeEach(() => {
createWrapper({ username: '' });
});
it('should NOT render username', () => {
expect(findUserName().exists()).toBe(false);
});
});
describe('avatar-badge slot', () => {
const badge = '<span>User badge</span>';
beforeEach(() => {
createWrapper(defaultProps, {
'avatar-badge': badge,
});
});
it('should render provided `avatar-badge` slot content', () => {
expect(wrapper.html()).toContain(badge);
});
});
});

View File

@ -1,51 +1,102 @@
import { shallowMount } from '@vue/test-utils';
import { GlAvatarLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'spec/test_constants';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import UserAvatarLinkNew from '~/vue_shared/components/user_avatar/user_avatar_link_new.vue';
import UserAvatarLinkOld from '~/vue_shared/components/user_avatar/user_avatar_link_old.vue';
const PROVIDED_PROPS = {
size: 32,
imgSrc: 'myavatarurl.com',
imgAlt: 'mydisplayname',
cssClasses: 'myextraavatarclass',
tooltipText: 'tooltip text',
tooltipPlacement: 'bottom',
};
describe('User Avatar Link Component', () => {
let wrapper;
const createWrapper = (props = {}, { glAvatarForAllUserAvatars } = {}) => {
wrapper = shallowMount(UserAvatarLink, {
const findUserName = () => wrapper.findByTestId('user-avatar-link-username');
const defaultProps = {
linkHref: `${TEST_HOST}/myavatarurl.com`,
imgSize: 32,
imgSrc: `${TEST_HOST}/myavatarurl.com`,
imgAlt: 'mydisplayname',
imgCssClasses: 'myextraavatarclass',
tooltipText: 'tooltip text',
tooltipPlacement: 'bottom',
username: 'username',
};
const createWrapper = (props, slots) => {
wrapper = shallowMountExtended(UserAvatarLink, {
propsData: {
...PROVIDED_PROPS,
...defaultProps,
...props,
},
provide: {
glFeatures: {
glAvatarForAllUserAvatars,
},
...slots,
},
});
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
describe.each([
[false, true, true],
[true, false, true],
[true, true, true],
[false, false, false],
])(
'when glAvatarForAllUserAvatars=%s and enforceGlAvatar=%s',
(glAvatarForAllUserAvatars, enforceGlAvatar, isUsingNewVersion) => {
it(`will render ${isUsingNewVersion ? 'new' : 'old'} version`, () => {
createWrapper({ enforceGlAvatar }, { glAvatarForAllUserAvatars });
expect(wrapper.findComponent(UserAvatarLinkNew).exists()).toBe(isUsingNewVersion);
expect(wrapper.findComponent(UserAvatarLinkOld).exists()).toBe(!isUsingNewVersion);
it('should render GlLink with correct props', () => {
const link = wrapper.findComponent(GlAvatarLink);
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe(defaultProps.linkHref);
});
it('should render UserAvatarImage and provide correct props to it', () => {
expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true);
expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({
cssClasses: defaultProps.imgCssClasses,
imgAlt: defaultProps.imgAlt,
imgSrc: defaultProps.imgSrc,
lazy: false,
size: defaultProps.imgSize,
tooltipPlacement: defaultProps.tooltipPlacement,
tooltipText: '',
});
});
describe('when username provided', () => {
beforeEach(() => {
createWrapper({ username: defaultProps.username });
});
it('should render provided username', () => {
expect(findUserName().text()).toBe(defaultProps.username);
});
it('should provide the tooltip data for the username', () => {
expect(findUserName().attributes()).toEqual(
expect.objectContaining({
title: defaultProps.tooltipText,
'tooltip-placement': defaultProps.tooltipPlacement,
}),
);
});
});
describe('when username is NOT provided', () => {
beforeEach(() => {
createWrapper({ username: '' });
});
it('should NOT render username', () => {
expect(findUserName().exists()).toBe(false);
});
});
describe('avatar-badge slot', () => {
const badge = '<span>User badge</span>';
beforeEach(() => {
createWrapper(defaultProps, {
'avatar-badge': badge,
});
},
);
});
it('should render provided `avatar-badge` slot content', () => {
expect(wrapper.html()).toContain(badge);
});
});
});

View File

@ -153,29 +153,4 @@ describe('UserAvatarList', () => {
});
});
});
describe('additional styling for the image', () => {
it('should not add CSS class when feature flag `glAvatarForAllUserAvatars` is disabled', () => {
factory({
propsData: { items: createList(1) },
});
const link = wrapper.findComponent(UserAvatarLink);
expect(link.props('imgCssClasses')).not.toBe('gl-mr-3');
});
it('should add CSS class when feature flag `glAvatarForAllUserAvatars` is enabled', () => {
factory({
propsData: { items: createList(1) },
provide: {
glFeatures: {
glAvatarForAllUserAvatars: true,
},
},
});
const link = wrapper.findComponent(UserAvatarLink);
expect(link.props('imgCssClasses')).toBe('gl-mr-3');
});
});
});

View File

@ -531,12 +531,14 @@ RSpec.describe GroupsHelper do
describe '#group_overview_tabs_app_data' do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let_it_be(:initial_sort) { 'created_asc' }
before do
allow(helper).to receive(:current_user).and_return(user)
allow(helper).to receive(:can?).with(user, :create_subgroup, group) { true }
allow(helper).to receive(:can?).with(user, :create_projects, group) { true }
allow(helper).to receive(:project_list_sort_by).and_return(initial_sort)
end
it 'returns expected hash' do
@ -545,7 +547,8 @@ RSpec.describe GroupsHelper do
subgroups_and_projects_endpoint: including("/groups/#{group.path}/-/children.json"),
shared_projects_endpoint: including("/groups/#{group.path}/-/shared_projects.json"),
archived_projects_endpoint: including("/groups/#{group.path}/-/children.json?archived=only"),
current_group_visibility: group.visibility
current_group_visibility: group.visibility,
initial_sort: initial_sort
}.merge(helper.group_overview_tabs_app_data(group))
)
end

View File

@ -96,9 +96,9 @@ module StubGitlabCalls
def stub_commonmark_sourcepos_disabled
render_options = Banzai::Filter::MarkdownEngines::CommonMark::RENDER_OPTIONS
allow_any_instance_of(Banzai::Filter::MarkdownEngines::CommonMark)
.to receive(:render_options)
.and_return(render_options)
allow_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance|
allow(instance).to receive(:render_options).and_return(render_options)
end
end
private