diff options
54 files changed, 862 insertions, 1245 deletions
diff --git a/.gitlab/ci/review-apps/dast-api.gitlab-ci.yml b/.gitlab/ci/review-apps/dast-api.gitlab-ci.yml new file mode 100644 index 00000000000..e2f32f120af --- /dev/null +++ b/.gitlab/ci/review-apps/dast-api.gitlab-ci.yml @@ -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] diff --git a/.gitlab/ci/review-apps/main.gitlab-ci.yml b/.gitlab/ci/review-apps/main.gitlab-ci.yml index 77e0bbff6a7..4c0a3579c92 100644 --- a/.gitlab/ci/review-apps/main.gitlab-ci.yml +++ b/.gitlab/ci/review-apps/main.gitlab-ci.yml @@ -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 @@ -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' diff --git a/Gemfile.checksum b/Gemfile.checksum index c22dc2c8804..1216b526045 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -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"}, diff --git a/Gemfile.lock b/Gemfile.lock index 8b023661438..2a42fa30826 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 956d62c29f3..93beb014099 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -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> diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 6caf5aa8e94..d74cb2d8175 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -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; diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue index 325e42af0f8..84e992b6365 100644 --- a/app/assets/javascripts/groups/components/overview_tabs.vue +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -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> diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index 223c2975c11..33bfcade336 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -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', + }, +]; diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js index 4fa3682c729..664d07ca13d 100644 --- a/app/assets/javascripts/groups/init_overview_tabs.js +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -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); diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 22fe3fe440e..f2f3d7ebeab 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -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 diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index c1e618620d8..6552a874c3a 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -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> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue deleted file mode 100644 index 6bd66981860..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue +++ /dev/null @@ -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> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue deleted file mode 100644 index 6e8c200d5c3..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue +++ /dev/null @@ -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> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index f80abed4d69..1a81da3eb0d 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -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> + + <span + v-if="shouldShowUsername" + v-gl-tooltip + :title="tooltipText" + :tooltip-placement="tooltipPlacement" + class="gl-ml-3" + data-testid="user-avatar-link-username" + > + {{ username }} + </span> - <user-avatar-link-old v-else v-bind="$props"> - <slot></slot> - <template #avatar-badge> - <slot name="avatar-badge"></slot> - </template> - </user-avatar-link-old> + <slot name="avatar-badge"></slot> + </gl-avatar-link> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue deleted file mode 100644 index 83551c689c4..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue +++ /dev/null @@ -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> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue deleted file mode 100644 index c2e46e61e1b..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue +++ /dev/null @@ -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> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue index 9da298ad705..231f5ff3d1f 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue @@ -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"> diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 267371c6b35..6b00c213875 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -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 diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml index 5cff0f562e5..fe455f4a0bc 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -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 diff --git a/config/feature_flags/development/gl_avatar_for_all_user_avatars.yml b/config/feature_flags/development/gl_avatar_for_all_user_avatars.yml deleted file mode 100644 index a3fee67a7f6..00000000000 --- a/config/feature_flags/development/gl_avatar_for_all_user_avatars.yml +++ /dev/null @@ -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 diff --git a/db/docs/project_wiki_repository_states.yml b/db/docs/project_wiki_repository_states.yml new file mode 100644 index 00000000000..b074eca3c89 --- /dev/null +++ b/db/docs/project_wiki_repository_states.yml @@ -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' diff --git a/db/migrate/20220928201920_create_project_wiki_repository_states.rb b/db/migrate/20220928201920_create_project_wiki_repository_states.rb new file mode 100644 index 00000000000..17ad5cf6b7a --- /dev/null +++ b/db/migrate/20220928201920_create_project_wiki_repository_states.rb @@ -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 diff --git a/db/schema_migrations/20220928201920 b/db/schema_migrations/20220928201920 new file mode 100644 index 00000000000..e77f9abf6a0 --- /dev/null +++ b/db/schema_migrations/20220928201920 @@ -0,0 +1 @@ +b2492ebefc3738dfe706379ef664d3f28315102acc1c0681ba67e6aae62861d7
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index e33f31226d8..d34e91f2100 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -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; diff --git a/doc/administration/object_storage.md b/doc/administration/object_storage.md index c2e27375221..0e85635b3d2 100644 --- a/doc/administration/object_storage.md +++ b/doc/administration/object_storage.md @@ -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` | diff --git a/doc/api/groups.md b/doc/api/groups.md index 56f1b7f7d7e..c1effc78a4d 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -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` | diff --git a/doc/api/projects.md b/doc/api/projects.md index 6bba45418eb..167434a1b32 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -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. | diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index fa6142802e6..e4011acde27 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -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= diff --git a/doc/integration/vault.md b/doc/integration/vault.md index 192e5f3a8ba..9440435aaa4 100644 --- a/doc/integration/vault.md +++ b/doc/integration/vault.md @@ -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: + +```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 +``` + +Replace: + +- `<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. **Sign in to Vault:** +The `oidc_scopes` field must include `openid`. - 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: +This configuration is saved under the name of the role you are creating. In this +example, we are creating a `demo` role. - ![Sign into Vault with GitLab](img/sign_into_vault_with_gitlab_v12_6.png) +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. - 1. Select **Authorize** to allow Vault to sign in through GitLab. This redirects you back to your Vault UI as a signed-in user. +## Sign in to Vault - ![Authorize Vault to connect with GitLab](img/authorize_vault_with_gitlab_v12_6.png) +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: -1. **Sign in using the Vault CLI** (optional): + ![Sign into Vault with GitLab](img/sign_into_vault_with_gitlab_v12_6.png) - Vault also allows you to sign in via their CLI. +1. To allow Vault to sign in through GitLab, select **Authorize**. This redirects you back to your Vault UI as a signed-in user. - 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: + ![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: + + - `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). - 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). + After running this command, you should see a link in the terminal. - 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: +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 diff --git a/doc/update/index.md b/doc/update/index.md index 8990cdb7482..06609f03952 100644 --- a/doc/update/index.md +++ b/doc/update/index.md @@ -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`. | diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md index d5cca7a8d18..b02461f5d9d 100644 --- a/doc/user/project/milestones/index.md +++ b/doc/user/project/milestones/index.md @@ -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**. diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md index e5408cd9bde..b77401a1098 100644 --- a/doc/user/project/quick_actions.md +++ b/doc/user/project/quick_actions.md @@ -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. | diff --git a/lib/banzai/filter/markdown_engines/common_mark.rb b/lib/banzai/filter/markdown_engines/common_mark.rb index cf368e28beb..7abfadc612b 100644 --- a/lib/banzai/filter/markdown_engines/common_mark.rb +++ b/lib/banzai/filter/markdown_engines/common_mark.rb @@ -56,3 +56,5 @@ module Banzai end end end + +Banzai::Filter::MarkdownEngines::CommonMark.prepend_mod diff --git a/lib/gitlab/database/gitlab_schemas.yml b/lib/gitlab/database/gitlab_schemas.yml index 8b62dfda677..0f93f6df54d 100644 --- a/lib/gitlab/database/gitlab_schemas.yml +++ b/lib/gitlab/database/gitlab_schemas.yml @@ -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 diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 2cd0ee7072d..d59b00f7797 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -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 diff --git a/qa/qa/page/component/groups_filter.rb b/qa/qa/page/component/groups_filter.rb index ff61c91f0f6..28b8ece918d 100644 --- a/qa/qa/page/component/groups_filter.rb +++ b/qa/qa/page/component/groups_filter.rb @@ -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 diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index f279af90aa3..a09c9d258dc 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -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 diff --git a/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb b/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb index 5aae5abaf10..1fa8f533869 100644 --- a/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb +++ b/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb @@ -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 diff --git a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb index 8225fcbfd89..09ac71d8ae8 100644 --- a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb +++ b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb @@ -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 diff --git a/spec/features/projects/user_sorts_projects_spec.rb b/spec/features/projects/user_sorts_projects_spec.rb index 15f7dae502d..49b9052ed3c 100644 --- a/spec/features/projects/user_sorts_projects_spec.rb +++ b/spec/features/projects/user_sorts_projects_spec.rb @@ -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 diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index 440f169be86..75d55376d09 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -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); }); diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 33d76a8571d..75f70bbf19d 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -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(); }); }); diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js index 379f509db82..be352301f43 100644 --- a/spec/frontend/groups/components/overview_tabs_spec.js +++ b/spec/frontend/groups/components/overview_tabs_spec.js @@ -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); + }); + }); + }); }); diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index 01494cb6a24..6fe60f3c2e6 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -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" diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js deleted file mode 100644 index f87737ca86a..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js +++ /dev/null @@ -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]); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js deleted file mode 100644 index 2c1be6ec47e..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js +++ /dev/null @@ -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]); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js index 6ad2ef226c2..d63b13981ac 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -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]); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js deleted file mode 100644 index f485a14cfea..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js deleted file mode 100644 index cf7a1025dba..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js index fd3f59008ec..df7ce449678 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -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); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js index b9accbf0373..1ad6d043399 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js @@ -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'); - }); - }); }); diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 0b6e51c26f5..a38483a956d 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -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 diff --git a/spec/support/helpers/stub_gitlab_calls.rb b/spec/support/helpers/stub_gitlab_calls.rb index 749554f7786..e5c30769531 100644 --- a/spec/support/helpers/stub_gitlab_calls.rb +++ b/spec/support/helpers/stub_gitlab_calls.rb @@ -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 |