Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-11-09 00:09:20 +00:00
parent 9f9d994f13
commit 5cd8380e46
49 changed files with 729 additions and 831 deletions

View File

@ -5,6 +5,7 @@ import { buildApiUrl } from './api_utils';
const GROUP_PATH = '/api/:version/groups/:id';
const GROUPS_PATH = '/api/:version/groups.json';
const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups';
const GROUP_TRANSFER_LOCATIONS_PATH = 'api/:version/groups/:id/transfer_locations';
const axiosGet = (url, query, options, callback) => {
return axios
@ -37,3 +38,10 @@ export function updateGroup(groupId, data = {}) {
return axios.put(url, data);
}
export const getGroupTransferLocations = (groupId, params = {}) => {
const url = buildApiUrl(GROUP_TRANSFER_LOCATIONS_PATH).replace(':id', groupId);
const defaultParams = { per_page: DEFAULT_PER_PAGE };
return axios.get(url, { params: { ...defaultParams, ...params } });
};

View File

@ -1,29 +1,24 @@
<script>
import { GlFormGroup } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue';
import TransferLocations from '~/groups_projects/components/transfer_locations.vue';
import { getGroupTransferLocations } from '~/api/groups_api';
export const i18n = {
confirmationMessage: __(
'You are going to transfer %{group_name} to another namespace. Are you ABSOLUTELY sure?',
),
emptyNamespaceTitle: __('No parent group'),
dropdownTitle: s__('GroupSettings|Select parent group'),
dropdownLabel: s__('GroupSettings|Select parent group'),
};
export default {
name: 'TransferGroupForm',
components: {
ConfirmDanger,
GlFormGroup,
NamespaceSelect,
TransferLocations,
},
props: {
groupNamespaces: {
type: Array,
required: true,
},
isPaidGroup: {
type: Boolean,
required: true,
@ -39,36 +34,41 @@ export default {
},
data() {
return {
selectedId: null,
selectedTransferLocation: null,
};
},
computed: {
disableSubmitButton() {
return this.isPaidGroup || !this.selectedId;
return this.isPaidGroup || !this.selectedTransferLocation;
},
selectedTransferLocationId() {
return this.selectedTransferLocation?.id;
},
},
methods: {
handleSelected({ id }) {
this.selectedId = id;
},
getGroupTransferLocations,
},
i18n,
additionalDropdownItems: [
{
id: -1,
humanName: i18n.emptyNamespaceTitle,
},
],
};
</script>
<template>
<div>
<gl-form-group v-if="!isPaidGroup">
<namespace-select
:default-text="$options.i18n.dropdownTitle"
:group-namespaces="groupNamespaces"
:empty-namespace-title="$options.i18n.emptyNamespaceTitle"
:include-headers="false"
include-empty-namespace
data-testid="transfer-group-namespace-select"
@select="handleSelected"
<input type="hidden" name="new_parent_group_id" :value="selectedTransferLocationId" />
<transfer-locations
v-if="!isPaidGroup"
v-model="selectedTransferLocation"
:show-user-transfer-locations="false"
data-testid="transfer-group-namespace"
:group-transfer-locations-api-method="getGroupTransferLocations"
:additional-dropdown-items="$options.additionalDropdownItems"
:label="$options.i18n.dropdownLabel"
/>
<input type="hidden" name="new_parent_group_id" :value="selectedId" />
</gl-form-group>
<confirm-danger
:disabled="disableSubmitButton"
:phrase="confirmationPhrase"

View File

@ -1,42 +1,38 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { sprintf } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import TransferGroupForm, { i18n } from './components/transfer_group_form.vue';
const prepareGroups = (rawGroups) => {
if (!rawGroups) {
return [];
}
return JSON.parse(rawGroups).map(({ id, text: humanName }) => ({
id,
humanName,
}));
};
export default () => {
const el = document.querySelector('.js-transfer-group-form');
if (!el) {
return false;
}
Vue.use(VueApollo);
const {
targetFormId = null,
buttonText: confirmButtonText = '',
groupName = '',
parentGroups,
groupId: resourceId,
isPaidGroup,
} = el.dataset;
return new Vue({
el,
apolloProvider: new VueApollo({
defaultClient: createDefaultClient(),
}),
provide: {
confirmDangerMessage: sprintf(i18n.confirmationMessage, { group_name: groupName }),
resourceId,
},
render(createElement) {
return createElement(TransferGroupForm, {
props: {
groupNamespaces: prepareGroups(parentGroups),
isPaidGroup: parseBoolean(isPaidGroup),
confirmButtonText,
confirmationPhrase: groupName,

View File

@ -5,6 +5,7 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
GlSearchBoxByType,
GlIntersectionObserver,
GlLoadingIcon,
@ -34,6 +35,7 @@ export default {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
GlSearchBoxByType,
GlIntersectionObserver,
GlLoadingIcon,
@ -49,6 +51,23 @@ export default {
type: Function,
required: true,
},
showUserTransferLocations: {
type: Boolean,
required: false,
default: true,
},
additionalDropdownItems: {
type: Array,
required: false,
default() {
return [];
},
},
label: {
type: String,
required: false,
default: i18n.SELECT_A_NAMESPACE,
},
},
initialTransferLocationsLoaded: false,
data() {
@ -56,6 +75,7 @@ export default {
searchTerm: '',
userTransferLocations: [],
groupTransferLocations: [],
filteredAdditionalDropdownItems: this.additionalDropdownItems,
isLoading: false,
isSearchLoading: false,
hasError: false,
@ -71,11 +91,14 @@ export default {
return this.groupTransferLocations.length;
},
selectedText() {
return this.value?.humanName || i18n.SELECT_A_NAMESPACE;
return this.value?.humanName || this.label;
},
hasNextPageOfGroups() {
return this.page < this.totalPages;
},
showAdditionalDropdownItems() {
return !this.isLoading && this.filteredAdditionalDropdownItems.length;
},
},
watch: {
searchTerm() {
@ -128,6 +151,10 @@ export default {
}
},
async getUserTransferLocations() {
if (!this.showUserTransferLocations) {
return [];
}
try {
const {
data: {
@ -167,6 +194,10 @@ export default {
this.groupTransferLocations = await this.getGroupTransferLocations();
this.filteredAdditionalDropdownItems = this.additionalDropdownItems.filter((dropdownItem) =>
dropdownItem.humanName.toLowerCase().includes(this.searchTerm.toLowerCase()),
);
this.isSearchLoading = false;
}, DEBOUNCE_DELAY),
handleError() {
@ -188,8 +219,15 @@ export default {
@dismiss="handleAlertDismiss"
>{{ $options.i18n.ERROR_MESSAGE }}</gl-alert
>
<gl-form-group :label="$options.i18n.SELECT_A_NAMESPACE">
<gl-dropdown :text="selectedText" data-qa-selector="namespaces_list" block @show="handleShow">
<gl-form-group :label="label">
<gl-dropdown
:text="selectedText"
data-qa-selector="namespaces_list"
data-testid="transfer-locations-dropdown"
block
toggle-class="gl-mb-0"
@show="handleShow"
>
<template #header>
<gl-search-box-by-type
v-model.trim="searchTerm"
@ -197,6 +235,15 @@ export default {
data-qa-selector="namespaces_list_search"
/>
</template>
<template v-if="showAdditionalDropdownItems">
<gl-dropdown-item
v-for="item in filteredAdditionalDropdownItems"
:key="item.id"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
<gl-dropdown-divider />
</template>
<div
v-if="hasUserTransferLocations"
data-qa-selector="namespaces_list_users"
@ -216,7 +263,9 @@ export default {
data-qa-selector="namespaces_list_groups"
data-testid="group-transfer-locations"
>
<gl-dropdown-section-header>{{ $options.i18n.GROUPS }}</gl-dropdown-section-header>
<gl-dropdown-section-header v-if="showUserTransferLocations">{{
$options.i18n.GROUPS
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="item in groupTransferLocations"
:key="item.id"

View File

@ -1,212 +0,0 @@
<script>
import {
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlIntersectionObserver,
GlLoadingIcon,
} from '@gitlab/ui';
import { __ } from '~/locale';
export const EMPTY_NAMESPACE_ID = -1;
export const i18n = {
DEFAULT_TEXT: __('Select a new namespace'),
DEFAULT_EMPTY_NAMESPACE_TEXT: __('No namespace'),
GROUPS: __('Groups'),
USERS: __('Users'),
};
const filterByName = (data, searchTerm = '') => {
if (!searchTerm) {
return data;
}
return data.filter((d) => d.humanName.toLowerCase().includes(searchTerm.toLowerCase()));
};
export default {
name: 'NamespaceSelectDeprecated',
components: {
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlIntersectionObserver,
GlLoadingIcon,
},
props: {
groupNamespaces: {
type: Array,
required: false,
default: () => [],
},
userNamespaces: {
type: Array,
required: false,
default: () => [],
},
fullWidth: {
type: Boolean,
required: false,
default: false,
},
defaultText: {
type: String,
required: false,
default: i18n.DEFAULT_TEXT,
},
includeHeaders: {
type: Boolean,
required: false,
default: true,
},
emptyNamespaceTitle: {
type: String,
required: false,
default: i18n.DEFAULT_EMPTY_NAMESPACE_TEXT,
},
includeEmptyNamespace: {
type: Boolean,
required: false,
default: false,
},
hasNextPageOfGroups: {
type: Boolean,
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
isSearchLoading: {
type: Boolean,
required: false,
default: false,
},
shouldFilterNamespaces: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
searchTerm: '',
selectedNamespace: null,
};
},
computed: {
hasUserNamespaces() {
return this.userNamespaces.length;
},
hasGroupNamespaces() {
return this.groupNamespaces.length;
},
filteredGroupNamespaces() {
if (!this.shouldFilterNamespaces) return this.groupNamespaces;
if (!this.hasGroupNamespaces) return [];
return filterByName(this.groupNamespaces, this.searchTerm);
},
filteredUserNamespaces() {
if (!this.shouldFilterNamespaces) return this.userNamespaces;
if (!this.hasUserNamespaces) return [];
return filterByName(this.userNamespaces, this.searchTerm);
},
selectedNamespaceText() {
return this.selectedNamespace?.humanName || this.defaultText;
},
filteredEmptyNamespaceTitle() {
const { includeEmptyNamespace, emptyNamespaceTitle, searchTerm } = this;
if (!includeEmptyNamespace) {
return '';
}
if (!searchTerm) {
return emptyNamespaceTitle;
}
return emptyNamespaceTitle.toLowerCase().includes(searchTerm.toLowerCase());
},
},
watch: {
searchTerm() {
this.$emit('search', this.searchTerm);
},
},
methods: {
handleSelect(item) {
this.selectedNamespace = item;
this.searchTerm = '';
this.$emit('select', item);
},
handleSelectEmptyNamespace() {
this.handleSelect({ id: EMPTY_NAMESPACE_ID, humanName: this.emptyNamespaceTitle });
},
},
i18n,
};
</script>
<template>
<gl-dropdown
:text="selectedNamespaceText"
:block="fullWidth"
data-qa-selector="namespaces_list"
@show="$emit('show')"
>
<template #header>
<gl-search-box-by-type
v-model.trim="searchTerm"
:is-loading="isSearchLoading"
data-qa-selector="namespaces_list_search"
/>
</template>
<div v-if="filteredEmptyNamespaceTitle">
<gl-dropdown-item
data-qa-selector="namespaces_list_item"
@click="handleSelectEmptyNamespace()"
>
{{ emptyNamespaceTitle }}
</gl-dropdown-item>
<gl-dropdown-divider />
</div>
<div
v-if="hasUserNamespaces"
data-qa-selector="namespaces_list_users"
data-testid="namespace-list-users"
>
<gl-dropdown-section-header v-if="includeHeaders">{{
$options.i18n.USERS
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="item in filteredUserNamespaces"
:key="item.id"
data-qa-selector="namespaces_list_item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
</div>
<div
v-if="hasGroupNamespaces"
data-qa-selector="namespaces_list_groups"
data-testid="namespace-list-groups"
>
<gl-dropdown-section-header v-if="includeHeaders">{{
$options.i18n.GROUPS
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="item in filteredGroupNamespaces"
:key="item.id"
data-qa-selector="namespaces_list_item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
</div>
<gl-loading-icon v-if="isLoading" class="gl-mb-3" size="sm" />
<gl-intersection-observer v-if="hasNextPageOfGroups" @appear="$emit('load-more-groups')" />
</gl-dropdown>
</template>

View File

@ -277,139 +277,128 @@
.description p {
margin-bottom: 0;
color: $gl-text-color-secondary;
@include str-truncated(100%);
}
}
.projects-list {
@include basic-list;
display: flex;
flex-direction: column;
@include gl-display-table;
.project-row {
@include gl-display-table-row;
}
.project-cell {
@include gl-display-table-cell;
@include gl-border-b;
@include gl-vertical-align-top;
@include gl-py-4;
}
.project-row:last-of-type {
.project-cell {
@include gl-border-none;
}
}
&.admin-projects,
&.group-settings-projects {
.project-row {
@include basic-list-stats;
display: flex;
align-items: center;
padding: $gl-padding-12 0;
}
h2 {
font-size: $gl-font-size;
font-weight: $gl-font-weight-bold;
margin-bottom: 0;
@include media-breakpoint-up(sm) {
.namespace-name {
font-weight: $gl-font-weight-normal;
}
}
}
.avatar-container {
flex: 0 0 auto;
align-self: flex-start;
}
.project-details {
min-width: 0;
line-height: $gl-line-height;
.flex-wrapper {
min-width: 0;
margin-top: -$gl-padding-8; // negative margin required for flex-wrap
flex: 1 1 100%;
.project-title {
line-height: 20px;
}
}
p,
.commit-row-message {
@include str-truncated(100%);
margin-bottom: 0;
}
.user-access-role {
margin: 0;
}
.description {
line-height: 1.5;
color: $gl-text-color-secondary;
}
@include media-breakpoint-down(md) {
.user-access-role {
line-height: $gl-line-height-14;
}
}
}
.ci-status-link {
display: inline-block;
line-height: 17px;
vertical-align: middle;
&:hover {
text-decoration: none;
.description > p {
@include gl-mb-0;
}
}
.controls {
@include media-breakpoint-down(xs) {
margin-top: $gl-padding-8;
@include gl-line-height-42;
}
}
@include media-breakpoint-up(sm) {
margin-top: 0;
.project-details {
max-width: 625px;
p,
.commit-row-message {
@include gl-mb-0;
@include str-truncated(100%);
}
.description {
line-height: 1.5;
max-height: $gl-spacing-scale-8;
}
}
.ci-status-link {
@include gl-text-decoration-none;
}
&:not(.compact) {
.controls {
@include media-breakpoint-up(lg) {
flex: 1 1 40%;
}
.icon-wrapper {
color: inherit;
margin-right: $gl-padding;
@include media-breakpoint-down(md) {
margin-right: 0;
margin-left: $gl-padding-8;
}
@include media-breakpoint-down(xs) {
&:first-child {
margin-left: 0;
}
}
}
@include gl-justify-content-start;
@include gl-pr-9;
&:not(.with-pipeline-status) {
.icon-wrapper:first-of-type {
@include media-breakpoint-up(lg) {
margin-left: $gl-padding-32;
@include gl-ml-7;
}
}
}
}
}
.project-details {
p,
.commit-row-message {
@include gl-white-space-normal;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -webkit-box;
}
}
}
.controls {
@include media-breakpoint-up(sm) {
@include gl-justify-content-end;
}
.icon-wrapper {
@include media-breakpoint-down(md) {
@include gl-mr-0;
@include gl-ml-3;
}
@include media-breakpoint-down(xs) {
&:first-child {
@include gl-ml-0;
}
}
}
.ci-status-link {
display: inline-flex;
@include gl-display-inline-flex;
}
}
.icon-container {
@include media-breakpoint-down(xs) {
margin-right: $gl-padding-8;
@include media-breakpoint-up(lg) {
margin-right: $gl-spacing-scale-7;
}
}
&.compact {
.project-row {
padding: $gl-padding 0;
}
h2 {
font-size: $gl-font-size;
.description {
@include gl-w-full;
@include gl-display-table;
@include gl-table-layout-fixed;
}
.avatar-container {
@ -422,27 +411,15 @@
}
}
.controls {
@include media-breakpoint-up(sm) {
margin-top: 0;
}
}
.updated-note {
@include media-breakpoint-up(sm) {
margin-top: $gl-padding-8;
@include gl-mt-2;
}
}
.icon-wrapper {
margin-left: $gl-padding-8;
margin-right: 0;
@include media-breakpoint-down(xs) {
&:first-child {
margin-left: 0;
}
}
@include gl-ml-3;
@include gl-mr-0;
}
.user-access-role {
@ -451,10 +428,6 @@
}
@include media-breakpoint-down(md) {
h2 {
font-size: $gl-font-size;
}
.avatar-container {
@include avatar-size(40px, 10px);
min-height: 40px;
@ -468,24 +441,18 @@
@include media-breakpoint-down(md) {
.updated-note {
margin-top: $gl-padding-8;
text-align: right;
@include gl-mt-3;
@include gl-text-right;
}
}
.forks,
.pipeline-status,
.updated-note {
display: flex;
@include gl-display-flex;
}
@include media-breakpoint-down(md) {
&:not(.explore) {
.forks {
display: none;
}
}
&.explore {
.pipeline-status,
.updated-note {
@ -496,8 +463,8 @@
@include media-breakpoint-down(xs) {
.updated-note {
margin-top: 0;
text-align: left;
@include gl-mt-0;
@include gl-text-left;
}
}
}

View File

@ -112,16 +112,6 @@ module GroupsHelper
s_("GroupSettings|Available only on the top-level group. Applies to all subgroups. Groups already shared with a group outside %{group} are still shared unless removed manually.").html_safe % { group: link_to_group(group) }
end
def parent_group_options(current_group)
exclude_groups = current_group.self_and_descendants.pluck_primary_key
exclude_groups << current_group.parent_id if current_group.parent_id
groups = GroupsFinder.new(current_user, min_access_level: Gitlab::Access::OWNER, exclude_group_ids: exclude_groups).execute.sort_by(&:human_name).map do |group|
{ id: group.id, text: group.human_name }
end
Gitlab::Json.dump(groups)
end
def render_setting_to_allow_project_access_token_creation?(group)
group.root? && current_user.can?(:admin_setting_to_allow_project_access_token_creation, group)
end

View File

@ -478,6 +478,10 @@ module ProjectsHelper
localized_access_names[access] || Gitlab::Access.human_access(access)
end
def badge_count(number)
format_cached_count(1000, number)
end
private
def localized_access_names

View File

@ -6,8 +6,23 @@ module Enums
library: 0
}.with_indifferent_access.freeze
PURL_TYPES = {
composer: 1, # refered to as `packagist` in gemnasium-db
conan: 2,
gem: 3,
golang: 4, # refered to as `go` in gemnasium-db
maven: 5,
npm: 6,
nuget: 7,
pypi: 8
}.with_indifferent_access.freeze
def self.component_types
COMPONENT_TYPES
end
def self.purl_types
PURL_TYPES
end
end
end

View File

@ -5,7 +5,7 @@
- c.body do
= render 'shared/projects/list',
projects: contributed_projects.sort_by(&:star_count).reverse,
projects_limit: 5, stars: true, avatar: false
projects_limit: 5, stars: true, avatar: false, compact_mode: true
- if local_assigns.has_key?(:projects) && projects.present?
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }) do |c|
@ -14,4 +14,4 @@
- c.body do
= render 'shared/projects/list',
projects: projects.sort_by(&:star_count).reverse,
projects_limit: 10, stars: true, avatar: false
projects_limit: 10, stars: true, avatar: false, compact_mode: true

View File

@ -1,5 +1,5 @@
- form_id = "transfer-group-form"
- initial_data = { button_text: s_('GroupSettings|Transfer group'), group_name: @group.name, target_form_id: form_id, parent_groups: parent_group_options(group), is_paid_group: group.paid?.to_s }
- initial_data = { button_text: s_('GroupSettings|Transfer group'), group_name: @group.name, group_id: @group.id, target_form_id: form_id, is_paid_group: group.paid?.to_s }
.sub-section
%h4.warning-title= s_('GroupSettings|Transfer group')

View File

@ -32,7 +32,7 @@
- if any_projects?(projects)
- load_pipeline_status(projects) if pipeline_status
- load_max_project_member_accesses(projects) # Prime cache used in shared/projects/project view rendered below
%ul.projects-list{ class: css_classes }
%ul.projects-list.gl-text-secondary.gl-w-full.gl-my-2{ class: css_classes }
- projects.each_with_index do |project, i|
- css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
= render "shared/projects/project", project: project, skip_namespace: skip_namespace,

View File

@ -8,61 +8,57 @@
- access = max_project_member_access(project)
- compact_mode = false unless local_assigns[:compact_mode] == true
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project)
- css_class = '' unless local_assigns[:css_class]
- css_class += " gl-display-flex!"
- css_class = "gl-sm-display-flex gl-align-items-center gl-vertical-align-middle!" if project.description.blank? && !show_last_commit_as_description
- cache_key = project_list_cache_key(project, pipeline_status: pipeline_status)
- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
- last_pipeline = project.last_pipeline if show_pipeline_status_icon
- css_controls_class = compact_mode ? [] : ["flex-lg-row", "justify-content-lg-between"]
- css_controls_class << "with-pipeline-status" if show_pipeline_status_icon && last_pipeline.present?
- avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar'
- css_controls_class = "with-pipeline-status" if show_pipeline_status_icon && last_pipeline.present?
- css_controls_container_class = compact_mode ? "" : "gl-lg-flex-direction-row gl-justify-content-space-between"
- css_metadata_classes = "gl-display-flex gl-align-items-center gl-mr-5 gl-reset-color! icon-wrapper has-tooltip"
%li.project-row.gl-align-items-center{ class: css_class }
%li.project-row
= cache(cache_key) do
- if avatar
.flex-grow-0.flex-shrink-0{ class: avatar_container_class }
.project-cell.gl-w-11
= link_to project_path(project), class: dom_class(project) do
- if project.creator && use_creator_avatar
= render Pajamas::AvatarComponent.new(project.creator, size: 48, alt: '', class: 'gl-mr-5')
- else
= render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5')
.project-details.d-sm-flex.flex-sm-fill.align-items-center{ data: { qa_selector: 'project_content', qa_project_name: project.name } }
.flex-wrapper
.d-flex.align-items-center.flex-wrap.project-title
%h2.d-flex.gl-mt-3
= link_to project_path(project), class: 'text-plain js-prefetch-document' do
%span.project-full-name.gl-mr-3><
%span.namespace-name
.project-cell{ class: css_class }
.project-details.gl-pr-9.gl-sm-pr-0.gl-w-full.gl-display-flex.gl-flex-direction-column{ data: { qa_selector: 'project_content', qa_project_name: project.name } }
.gl-display-flex.gl-align-items-center.gl-flex-wrap-wrap
%h2.gl-font-base.gl-line-height-20.gl-my-0
= link_to project_path(project), class: 'text-plain gl-mr-3 js-prefetch-document' do
%span.namespace-name.gl-font-weight-normal
- if project.namespace && !skip_namespace
= project.namespace.human_name
\/
%span.project-name<
= project.name
%span.metadata-info.visibility-icon.gl-mr-3.gl-mt-3.text-secondary.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) }
%span.gl-mr-3.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) }
= visibility_level_icon(project.visibility_level)
- if explore_projects_tab? && project_license_name(project)
%span.metadata-info.d-inline-flex.align-items-center.gl-mr-3.gl-mt-3
%span.gl-display-inline-flex.gl-align-items-center.gl-mr-3
= sprite_icon('scale', size: 14, css_class: 'gl-mr-2')
= project_license_name(project)
- if !explore_projects_tab? && access&.nonzero?
-# haml-lint:disable UnnecessaryStringOutput
= ' ' # prevent haml from eating the space between elements
.metadata-info.gl-mt-3
%span.user-access-role.gl-display-block{ data: { qa_selector: 'user_role_content' } }= localized_project_human_access(access)
%span.user-access-role.gl-display-block.gl-m-0{ data: { qa_selector: 'user_role_content' } }= Gitlab::Access.human_access(access)
- if !explore_projects_tab?
.metadata-info.gl-mt-3
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project
- if show_last_commit_as_description
.description.d-none.d-sm-block.gl-mr-3
.description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2
= link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
- elsif project.description.present?
.description.d-none.d-sm-block.gl-mr-3
.description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2
= markdown_field(project, :description)
- if project.topics.any?
@ -71,39 +67,49 @@
= render_if_exists 'shared/projects/removed', project: project
.controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class.join(" ") }
.icon-container.d-flex.align-items-center
.gl-display-flex.gl-mt-3{ class: "#{css_class} gl-sm-display-none!" }
.controls.gl-display-flex.gl-align-items-center
- if show_pipeline_status_icon && last_pipeline.present?
- pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
%span.icon-wrapper.pipeline-status
%span.icon-wrapper.pipeline-status.gl-mr-5
= render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
= render_if_exists 'shared/projects/archived', project: project
- if stars
= link_to project_starrers_path(project),
class: "d-flex align-items-center icon-wrapper stars has-tooltip",
title: _('Stars'), data: { container: 'body', placement: 'top' } do
= sprite_icon('star', size: 14, css_class: 'gl-mr-2')
= number_with_delimiter(project.star_count)
- if forks
= link_to project_forks_path(project),
class: "align-items-center icon-wrapper forks has-tooltip",
title: _('Forks'), data: { container: 'body', placement: 'top' } do
= sprite_icon('fork', size: 14, css_class: 'gl-mr-2')
= number_with_delimiter(project.forks_count)
- if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode)
= link_to project_merge_requests_path(project),
class: "d-none d-xl-flex align-items-center icon-wrapper merge-requests has-tooltip",
title: _('Merge requests'), data: { container: 'body', placement: 'top' } do
= sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2')
= number_with_delimiter(project.open_merge_requests_count)
- if show_issue_count?(disabled: !issues, compact_mode: compact_mode)
= link_to project_issues_path(project),
class: "d-none d-xl-flex align-items-center icon-wrapper issues has-tooltip",
title: _('Issues'), data: { container: 'body', placement: 'top' } do
= sprite_icon('issues', size: 14, css_class: 'gl-mr-2')
= number_with_delimiter(project.open_issues_count)
.updated-note
= link_to project_starrers_path(project), class: "#{css_metadata_classes} stars", title: _('Stars'), data: { container: 'body', placement: 'top' } do
= sprite_icon('star-o', size: 14, css_class: 'gl-mr-2')
= badge_count(project.star_count)
.updated-note.gl-ml-3.gl-sm-ml-0
%span
= _('Updated')
= updated_tooltip
.project-cell{ class: "#{css_class} gl-xs-display-none!" }
.project-controls.gl-display-flex.gl-flex-direction-column.gl-w-full{ class: css_controls_container_class, data: { testid: 'project_controls'} }
.controls.gl-display-flex.gl-align-items-center{ class: css_controls_class }
- if show_pipeline_status_icon && last_pipeline.present?
- pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
%span.icon-wrapper.pipeline-status.gl-mr-5
= render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
= render_if_exists 'shared/projects/archived', project: project
- if stars
= link_to project_starrers_path(project), class: "#{css_metadata_classes} stars", title: _('Stars'), data: { container: 'body', placement: 'top' } do
= sprite_icon('star-o', size: 14, css_class: 'gl-mr-2')
= badge_count(project.star_count)
- if forks
= link_to project_forks_path(project), class: "#{css_metadata_classes} forks", title: _('Forks'), data: { container: 'body', placement: 'top' } do
= sprite_icon('fork', size: 14, css_class: 'gl-mr-2')
= badge_count(project.forks_count)
- if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode)
= link_to project_merge_requests_path(project), class: "#{css_metadata_classes} merge-requests", title: _('Merge requests'), data: { container: 'body', placement: 'top' } do
= sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2')
= badge_count(project.open_merge_requests_count)
- if show_issue_count?(disabled: !issues, compact_mode: compact_mode)
= link_to project_issues_path(project), class: "#{css_metadata_classes} issues", title: _('Issues'), data: { container: 'body', placement: 'top' } do
= sprite_icon('issues', size: 14, css_class: 'gl-mr-2')
= badge_count(project.open_issues_count)
.updated-note.gl-white-space-nowrap.gl-justify-content-end
%span
= _('Updated')
= updated_tooltip

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddPurlTypeToSbomComponents < Gitlab::Database::Migration[2.0]
def change
add_column :sbom_components, :purl_type, :smallint
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class RemoveUniqueIndexOnSbomComponentsTypeAndName < Gitlab::Database::Migration[2.0]
INDEX_NAME = 'index_sbom_components_on_component_type_and_name'
disable_ddl_transaction!
def up
remove_concurrent_index_by_name :sbom_components, name: INDEX_NAME
end
def down
add_concurrent_index :sbom_components, [:component_type, :name], unique: true, name: INDEX_NAME
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddUniqueIndexOnSbomComponentsTypeNameAndPurlType < Gitlab::Database::Migration[2.0]
INDEX_NAME = 'index_sbom_components_on_component_type_name_and_purl_type'
disable_ddl_transaction!
def up
add_concurrent_index :sbom_components, [:name, :purl_type, :component_type], unique: true, name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :sbom_components, name: INDEX_NAME
end
end

View File

@ -0,0 +1 @@
f1f30c3581e35a92f3ede694e1eb70c6fc4dccfdb9e377b5f9046e18eaca2c54

View File

@ -0,0 +1 @@
33bbeaa1d94cfa936de422fcc2f0456d235dde13072f6907cd514a12956ef9aa

View File

@ -0,0 +1 @@
0e985bac7558768e0b97316c1362cb411fed5605c0a313c3872e86f7242f8d36

View File

@ -21119,6 +21119,7 @@ CREATE TABLE sbom_components (
updated_at timestamp with time zone NOT NULL,
component_type smallint NOT NULL,
name text NOT NULL,
purl_type smallint,
CONSTRAINT check_91a8f6ad53 CHECK ((char_length(name) <= 255))
);
@ -30483,7 +30484,7 @@ CREATE INDEX index_sbom_component_versions_on_component_id ON sbom_component_ver
CREATE UNIQUE INDEX index_sbom_component_versions_on_component_id_and_version ON sbom_component_versions USING btree (component_id, version);
CREATE UNIQUE INDEX index_sbom_components_on_component_type_and_name ON sbom_components USING btree (component_type, name);
CREATE UNIQUE INDEX index_sbom_components_on_component_type_name_and_purl_type ON sbom_components USING btree (name, purl_type, component_type);
CREATE INDEX index_sbom_occurrences_on_component_id ON sbom_occurrences USING btree (component_id);

View File

@ -536,7 +536,7 @@ POST /projects/:id/repository/commits/:sha/comments
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" \
--form "note=Nice picture man\!" --form "path=dudeism.md" --form "line=11" --form "line_type=new" \
--form "note=Nice picture\!" --form "path=README.md" --form "line=11" --form "line_type=new" \
"https://gitlab.example.com/api/v4/projects/17/repository/commits/18f3e63d05582537db6d183d9d557be09e1f90c8/comments"
```
@ -554,9 +554,9 @@ Example response:
},
"created_at" : "2016-01-19T09:44:55.600Z",
"line_type" : "new",
"path" : "dudeism.md",
"path" : "README.md",
"line" : 11,
"note" : "Nice picture man!"
"note" : "Nice picture!"
}
```

View File

@ -223,7 +223,7 @@ Example response:
}],
"compare_timeout": false,
"compare_same_ref": false,
"web_url": "https://gitlab.example.com/thedude/gitlab-foss/-/compare/ae73cb07c9eeaf35924a10f713b364d32b2dd34f...0b4bc9a49b562e85de7cc9e834518ea6828729b9"
"web_url": "https://gitlab.example.com/janedoe/gitlab-foss/-/compare/ae73cb07c9eeaf35924a10f713b364d32b2dd34f...0b4bc9a49b562e85de7cc9e834518ea6828729b9"
}
```

View File

@ -289,7 +289,7 @@ contains spaces in its title. Observe how spaces are escaped using the `%20`
ASCII code.
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/42/issues?title=Hello%20Dude"
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/42/issues?title=Hello%20GitLab"
```
Use `%2F` for slashes (`/`).

View File

@ -18,6 +18,7 @@ In addition to this page, the following resources can help you craft and contrib
- [Recommended word list](word_list.md)
- [Doc style and consistency testing](../testing.md)
- [Guidelines for UI error messages](https://design.gitlab.com/content/error-messages/)
- [Documentation global navigation](../site_architecture/global_nav.md)
- [GitLab Handbook style guidelines](https://about.gitlab.com/handbook/communication/#writing-style-guidelines)
- [Microsoft Style Guide](https://learn.microsoft.com/en-us/style-guide/welcome/)
- [Google Developer Documentation Style Guide](https://developers.google.com/style)

View File

@ -66,7 +66,7 @@ module API
optional :ref, type: String, desc: 'The ref',
documentation: { example: 'develop' }
optional :target_url, type: String, desc: 'The target URL to associate with this status',
documentation: { example: 'https://gitlab.example.com/thedude/gitlab-foss/builds/91' }
documentation: { example: 'https://gitlab.example.com/janedoe/gitlab-foss/builds/91' }
optional :description, type: String, desc: 'A short description of the status'
optional :name, type: String, desc: 'A string label to differentiate this status from the status of other systems',
documentation: { example: 'coverage', default: 'default' }

View File

@ -10,7 +10,7 @@ module API
expose :name, documentation: { type: 'string', example: 'default' }
expose :target_url, documentation: {
type: 'string',
example: 'https://gitlab.example.com/thedude/gitlab-foss/builds/91'
example: 'https://gitlab.example.com/janedoe/gitlab-foss/builds/91'
}
expose :description, documentation: { type: 'string' }
expose :created_at, documentation: { type: 'dateTime', example: '2016-01-19T09:05:50.355Z' }

View File

@ -61,23 +61,19 @@ module Gitlab
end
def parse_components
data['components']&.each do |component_data|
type = component_data['type']
next unless supported_component_type?(type)
data['components']&.each_with_index do |component_data, index|
component = ::Gitlab::Ci::Reports::Sbom::Component.new(
type: type,
type: component_data['type'],
name: component_data['name'],
purl: component_data['purl'],
version: component_data['version']
)
report.add_component(component)
report.add_component(component) if component.ingestible?
rescue ::Sbom::PackageUrl::InvalidPackageURL
report.add_error("/components/#{index}/purl is invalid")
end
end
def supported_component_type?(type)
::Enums::Sbom.component_types.include?(type.to_sym)
end
end
end
end

View File

@ -7,11 +7,34 @@ module Gitlab
class Component
attr_reader :component_type, :name, :version
def initialize(type:, name:, version:)
def initialize(type:, name:, purl:, version:)
@component_type = type
@name = name
@purl = purl
@version = version
end
def ingestible?
supported_component_type? && supported_purl_type?
end
def purl
return unless @purl
::Sbom::PackageUrl.parse(@purl)
end
private
def supported_component_type?
::Enums::Sbom.component_types.include?(component_type.to_sym)
end
def supported_purl_type?
return true unless purl
::Enums::Sbom.purl_types.include?(purl.type.to_sym)
end
end
end
end

View File

@ -12,6 +12,10 @@ module Gitlab
@errors = []
end
def valid?
errors.empty?
end
def add_error(error)
errors << error
end

View File

@ -90,10 +90,10 @@ module Sbom
@subpath = subpath
end
# Creates a new PackageURL from a string.
# Creates a new PackageUrl from a string.
# @param [String] string The package URL string.
# @raise [InvalidPackageURL] If the string is not a valid package URL.
# @return [PackageURL]
# @return [PackageUrl]
def self.parse(string)
Decoder.new(string).decode!
end

View File

@ -95,7 +95,7 @@ module Sbom
# - The left side lowercased is the type: `type`
# - The right side is the remainder: `namespace/name@version`
@type, @string = partition(@string, '/', from: :left)
raise InvalidPackageURL, 'invalid or missing package type' if @type.empty?
raise InvalidPackageURL, 'invalid or missing package type' if @type.blank?
end
def decode_version!
@ -123,7 +123,7 @@ module Sbom
def decode_namespace!
# If there is anything remaining, this is the namespace.
# The namespace may contain multiple segments delimited by `/`.
@namespace = decode_segments(@string, &:empty?) unless @string.empty?
@namespace = decode_segments(@string, &:empty?) if @string.present?
end
def decode_segment(segment)

View File

@ -27216,9 +27216,6 @@ msgstr ""
msgid "No milestone"
msgstr ""
msgid "No namespace"
msgstr ""
msgid "No other labels with such name or description"
msgstr ""

View File

@ -9,7 +9,7 @@ module QA
def self.included(base)
super
base.view "app/assets/javascripts/vue_shared/components/namespace_select/namespace_select_deprecated.vue" do
base.view "app/assets/javascripts/groups_projects/components/transfer_locations.vue" do
element :namespaces_list
element :namespaces_list_groups
element :namespaces_list_item

View File

@ -39,7 +39,34 @@ module Helper
def ast(source, file: '', version: nil)
version ||= ruby_version
puts RuboCop::AST::ProcessedSource.new(source, version).ast.to_s
ast = RuboCop::AST::ProcessedSource.new(source, version).ast
return ast if ast
warn "Syntax error in `#{source}`."
end
def pattern(string)
RuboCop::NodePattern.new(string)
end
def help!
puts <<~HELP
Use `ast(source_string, version: nil)` method to parse code and return its AST.
Use `pattern(string)` to compile RuboCop's node patterns.
See https://docs.rubocop.org/rubocop-ast/node_pattern.html.
Examples:
node = ast('puts :hello')
pat = pattern('`(sym :hello)')
pat.match(node) # => true
HELP
nil
end
def ruby_version
@ -56,11 +83,12 @@ def start_irb
include Helper # rubocop:disable Style/MixinUsage
puts "Ruby version: #{ruby_version}"
puts
puts "Use `ast(source_string, version: nil)` method to parse code and output AST. For example:"
puts " ast('puts :hello')"
puts
puts <<~BANNER
Ruby version: #{ruby_version}
Type `help!` for instructions and examples.
BANNER
IRB.start
end
@ -103,12 +131,12 @@ elsif options.interactive
start_irb
end
elsif options.eval
Helper.ast(options.eval)
puts Helper.ast(options.eval)
elsif files.any?
files.each do |file|
if File.file?(file)
source = File.read(file)
Helper.ast(source, file: file)
puts Helper.ast(source, file: file)
else
warn "Skipping non-file #{file.inspect}"
end

View File

@ -3,15 +3,29 @@
FactoryBot.define do
factory :ci_reports_sbom_component, class: '::Gitlab::Ci::Reports::Sbom::Component' do
type { "library" }
sequence(:name) { |n| "component-#{n}" }
sequence(:version) { |n| "v0.0.#{n}" }
transient do
purl_type { 'npm' }
end
purl do
::Sbom::PackageUrl.new(
type: purl_type,
name: name,
version: version
).to_s
end
skip_create
initialize_with do
::Gitlab::Ci::Reports::Sbom::Component.new(
type: type,
name: name,
purl: purl,
version: version
)
end

View File

@ -8,6 +8,12 @@ FactoryBot.define do
source { association :ci_reports_sbom_source }
end
trait :invalid do
after(:build) do |report, options|
report.add_error('This report is invalid because it contains errors.')
end
end
after(:build) do |report, options|
options.components.each { |component| report.add_component(component) }
report.set_source(options.source)

View File

@ -151,7 +151,7 @@ RSpec.describe 'Dashboard Projects' do
it 'shows that the last pipeline passed' do
visit dashboard_projects_path
page.within('.controls') do
page.within('[data-testid="project_controls"]') do
expect(page).to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit, ref: pipeline.ref)}']")
expect(page).to have_css('.ci-status-link')
expect(page).to have_css('.ci-status-icon-success')
@ -163,7 +163,7 @@ RSpec.describe 'Dashboard Projects' do
it 'does not show the pipeline status' do
visit dashboard_projects_path
page.within('.controls') do
page.within('[data-testid="project_controls"]') do
expect(page).not_to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit, ref: pipeline.ref)}']")
expect(page).not_to have_css('.ci-status-link')
expect(page).not_to have_css('.ci-status-icon-success')

View File

@ -150,13 +150,15 @@ RSpec.describe 'Edit group settings' do
it 'can successfully transfer the group' do
visit edit_group_path(selected_group)
page.within('.js-group-transfer-form') do
namespace_select.find('button').click
namespace_select.find('.dropdown-menu p', text: target_group_name, match: :first).click
click_button 'Transfer group'
page.within('[data-testid="transfer-locations-dropdown"]') do
click_button _('Select parent group')
fill_in _('Search'), with: target_group_name
wait_for_requests
click_button target_group_name
end
click_button s_('GroupSettings|Transfer group')
page.within(confirm_modal) do
expect(page).to have_text "You are going to transfer #{selected_group.name} to another namespace. Are you ABSOLUTELY sure?"
@ -169,16 +171,16 @@ RSpec.describe 'Edit group settings' do
end
end
context 'from a subgroup' do
context 'when transfering from a subgroup' do
let(:selected_group) { create(:group, path: 'foo-subgroup', parent: group) }
context 'to no parent group' do
context 'when transfering to no parent group' do
let(:target_group_name) { 'No parent group' }
it_behaves_like 'can transfer the group'
end
context 'to a different parent group' do
context 'when transfering to a parent group' do
let(:target_group) { create(:group, path: 'foo-parentgroup') }
let(:target_group_name) { target_group.name }
@ -190,16 +192,13 @@ RSpec.describe 'Edit group settings' do
end
end
context 'from a root group' do
context 'when transfering from a root group to a parent group' do
let(:selected_group) { create(:group, path: 'foo-rootgroup') }
context 'to a parent group' do
let(:target_group_name) { group.name }
it_behaves_like 'can transfer the group'
end
end
end
context 'disable email notifications' do
it 'is visible' do

View File

@ -1,10 +1,13 @@
import MockAdapter from 'axios-mock-adapter';
import getGroupTransferLocationsResponse from 'test_fixtures/api/groups/transfer_locations.json';
import httpStatus from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { updateGroup } from '~/api/groups_api';
import { DEFAULT_PER_PAGE } from '~/api';
import { updateGroup, getGroupTransferLocations } from '~/api/groups_api';
const mockApiVersion = 'v4';
const mockUrlRoot = '/gitlab';
const mockGroupId = '99';
describe('GroupsApi', () => {
let originalGon;
@ -27,7 +30,6 @@ describe('GroupsApi', () => {
});
describe('updateGroup', () => {
const mockGroupId = '99';
const mockData = { attr: 'value' };
const expectedUrl = `${mockUrlRoot}/api/${mockApiVersion}/groups/${mockGroupId}`;
@ -43,4 +45,25 @@ describe('GroupsApi', () => {
expect(res.data).toMatchObject({ id: mockGroupId, ...mockData });
});
});
describe('getGroupTransferLocations', () => {
beforeEach(() => {
jest.spyOn(axios, 'get');
});
it('retrieves transfer locations from the correct URL and returns them in the response data', async () => {
const params = { page: 1 };
const expectedUrl = `${mockUrlRoot}/api/${mockApiVersion}/groups/${mockGroupId}/transfer_locations`;
mock.onGet(expectedUrl).replyOnce(200, { data: getGroupTransferLocationsResponse });
await expect(getGroupTransferLocations(mockGroupId, params)).resolves.toMatchObject({
data: { data: getGroupTransferLocationsResponse },
});
expect(axios.get).toHaveBeenCalledWith(expectedUrl, {
params: { ...params, per_page: DEFAULT_PER_PAGE },
});
});
});
});

View File

@ -32,6 +32,26 @@ RSpec.describe 'Jobs (JavaScript fixtures)' do
end
end
describe API::Groups, type: :request do
let_it_be(:user) { create(:user) }
describe 'transfer_locations' do
let_it_be(:groups) { create_list(:group, 4) }
let_it_be(:transfer_from_group) { create(:group) }
before_all do
groups.each { |group| group.add_owner(user) }
transfer_from_group.add_owner(user)
end
it 'api/groups/transfer_locations.json' do
get api("/groups/#{transfer_from_group.id}/transfer_locations", user)
expect(response).to be_successful
end
end
end
describe GraphQL::Query, type: :request do
let_it_be(:user) { create(:user) }

View File

@ -1,8 +1,13 @@
import { GlAlert, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Component from '~/groups/components/transfer_group_form.vue';
import TransferLocationsForm, { i18n } from '~/groups/components/transfer_group_form.vue';
import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue';
import TransferLocations from '~/groups_projects/components/transfer_locations.vue';
import { getGroupTransferLocations } from '~/api/groups_api';
jest.mock('~/api/groups_api', () => ({
getGroupTransferLocations: jest.fn(),
}));
describe('Transfer group form', () => {
let wrapper;
@ -22,25 +27,25 @@ describe('Transfer group form', () => {
];
const defaultProps = {
groupNamespaces,
paidGroupHelpLink,
isPaidGroup: false,
confirmationPhrase,
confirmButtonText,
};
const createComponent = (propsData = {}) =>
shallowMountExtended(Component, {
const createComponent = (propsData = {}) => {
wrapper = shallowMountExtended(TransferLocationsForm, {
propsData: {
...defaultProps,
...propsData,
},
stubs: { GlSprintf },
});
};
const findAlert = () => wrapper.findComponent(GlAlert);
const findConfirmDanger = () => wrapper.findComponent(ConfirmDanger);
const findNamespaceSelect = () => wrapper.findComponent(NamespaceSelect);
const findTransferLocations = () => wrapper.findComponent(TransferLocations);
const findHiddenInput = () => wrapper.find('[name="new_parent_group_id"]');
afterEach(() => {
@ -49,21 +54,17 @@ describe('Transfer group form', () => {
describe('default', () => {
beforeEach(() => {
wrapper = createComponent();
createComponent();
});
it('renders the namespace select component', () => {
expect(findNamespaceSelect().exists()).toBe(true);
});
it('renders the transfer locations dropdown and passes correct props', () => {
findTransferLocations().props('groupTransferLocationsApiMethod')();
it('sets the namespace select properties', () => {
expect(findNamespaceSelect().props()).toMatchObject({
defaultText: 'Select parent group',
fullWidth: false,
includeHeaders: false,
emptyNamespaceTitle: 'No parent group',
includeEmptyNamespace: true,
groupNamespaces,
expect(getGroupTransferLocations).toHaveBeenCalled();
expect(findTransferLocations().props()).toMatchObject({
value: null,
label: i18n.dropdownLabel,
additionalDropdownItems: TransferLocationsForm.additionalDropdownItems,
});
});
@ -90,10 +91,15 @@ describe('Transfer group form', () => {
});
describe('with a selected project', () => {
const [firstGroup] = groupNamespaces;
const [selectedItem] = groupNamespaces;
beforeEach(() => {
wrapper = createComponent();
findNamespaceSelect().vm.$emit('select', firstGroup);
createComponent();
findTransferLocations().vm.$emit('input', selectedItem);
});
it('sets `value` prop on `TransferLocations` component', () => {
expect(findTransferLocations().props('value')).toEqual(selectedItem);
});
it('sets the confirm danger disabled property to false', () => {
@ -102,7 +108,7 @@ describe('Transfer group form', () => {
it('sets the hidden input field', () => {
expect(findHiddenInput().exists()).toBe(true);
expect(parseInt(findHiddenInput().attributes('value'), 10)).toBe(firstGroup.id);
expect(findHiddenInput().attributes('value')).toBe(String(selectedItem.id));
});
it('emits "confirm" event when the danger modal is confirmed', () => {
@ -116,15 +122,15 @@ describe('Transfer group form', () => {
describe('isPaidGroup = true', () => {
beforeEach(() => {
wrapper = createComponent({ isPaidGroup: true });
createComponent({ isPaidGroup: true });
});
it('disables the transfer button', () => {
expect(findConfirmDanger().props()).toMatchObject({ disabled: true });
});
it('hides the namespace selector button', () => {
expect(findNamespaceSelect().exists()).toBe(false);
it('hides the transfer locations dropdown', () => {
expect(findTransferLocations().exists()).toBe(false);
});
});
});

View File

@ -14,6 +14,7 @@ import transferLocationsResponsePage2 from 'test_fixtures/api/projects/transfer_
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import { __ } from '~/locale';
import TransferLocations from '~/groups_projects/components/transfer_locations.vue';
import { getTransferLocations } from '~/api/projects_api';
import currentUserNamespaceQuery from '~/projects/settings/graphql/queries/current_user_namespace.query.graphql';
@ -31,6 +32,10 @@ describe('TransferLocations', () => {
groupTransferLocationsApiMethod: getTransferLocations,
value: null,
};
const additionalDropdownItem = {
id: -1,
humanName: __('No parent group'),
};
// Mock requests
const defaultQueryHandler = jest.fn().mockResolvedValue(currentUserNamespaceQueryResponse);
@ -93,9 +98,13 @@ describe('TransferLocations', () => {
.findByTestId('group-transfer-locations')
.findAllComponents(GlDropdownItem)
.wrappers.map((dropdownItem) => dropdownItem.text());
const findDropdownItemByText = (text) =>
wrapper
.findAllComponents(GlDropdownItem)
.wrappers.find((dropdownItem) => dropdownItem.text() === text);
const findAlert = () => wrapper.findComponent(GlAlert);
const findSearch = () => wrapper.findComponent(GlSearchBoxByType);
const searchEmitInput = () => findSearch().vm.$emit('input', 'foo');
const searchEmitInput = (searchTerm = 'foo') => findSearch().vm.$emit('input', searchTerm);
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const intersectionObserverEmitAppear = () => findIntersectionObserver().vm.$emit('appear');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
@ -105,6 +114,15 @@ describe('TransferLocations', () => {
});
describe('when `GlDropdown` is opened', () => {
it('shows loading icon', async () => {
getTransferLocations.mockReturnValueOnce(new Promise(() => {}));
createComponent();
findDropdown().vm.$emit('show');
await nextTick();
expect(findLoadingIcon().exists()).toBe(true);
});
it('fetches and renders user and group transfer locations', async () => {
mockResolvedGetTransferLocations();
createComponent();
@ -118,6 +136,49 @@ describe('TransferLocations', () => {
);
});
describe('when `showUserTransferLocations` prop is `false`', () => {
it('does not fetch user transfer locations', async () => {
mockResolvedGetTransferLocations();
createComponent({
propsData: {
showUserTransferLocations: false,
},
});
await showDropdown();
expect(wrapper.findByTestId('user-transfer-locations').exists()).toBe(false);
});
});
describe('when `additionalDropdownItems` prop is passed', () => {
it('displays additional dropdown items', async () => {
mockResolvedGetTransferLocations();
createComponent({
propsData: {
additionalDropdownItems: [additionalDropdownItem],
},
});
await showDropdown();
expect(findDropdownItemByText(additionalDropdownItem.humanName).exists()).toBe(true);
});
describe('when loading', () => {
it('does not display additional dropdown items', async () => {
getTransferLocations.mockReturnValueOnce(new Promise(() => {}));
createComponent({
propsData: {
additionalDropdownItems: [additionalDropdownItem],
},
});
findDropdown().vm.$emit('show');
await nextTick();
expect(findDropdownItemByText(additionalDropdownItem.humanName)).toBeUndefined();
});
});
});
describe('when transfer locations have already been fetched', () => {
beforeEach(async () => {
mockResolvedGetTransferLocations();
@ -187,12 +248,12 @@ describe('TransferLocations', () => {
describe('when search is typed in', () => {
const transferLocationsResponseSearch = [transferLocationsResponsePage1[0]];
const arrange = async () => {
const arrange = async ({ propsData, searchTerm } = {}) => {
mockResolvedGetTransferLocations();
createComponent();
createComponent({ propsData });
await showDropdown();
mockResolvedGetTransferLocations({ data: transferLocationsResponseSearch });
searchEmitInput();
searchEmitInput(searchTerm);
await nextTick();
};
@ -215,6 +276,29 @@ describe('TransferLocations', () => {
transferLocationsResponseSearch.map((transferLocation) => transferLocation.full_name),
);
});
it('does not display additional dropdown items if they do not match the search', async () => {
await arrange({
propsData: {
additionalDropdownItems: [additionalDropdownItem],
},
});
await waitForPromises();
expect(findDropdownItemByText(additionalDropdownItem.humanName)).toBeUndefined();
});
it('displays additional dropdown items if they match the search', async () => {
await arrange({
propsData: {
additionalDropdownItems: [additionalDropdownItem],
},
searchTerm: 'No par',
});
await waitForPromises();
expect(findDropdownItemByText(additionalDropdownItem.humanName).exists()).toBe(true);
});
});
describe('when there are no more pages', () => {
@ -280,4 +364,14 @@ describe('TransferLocations', () => {
);
});
});
describe('when `label` prop is passed', () => {
it('renders label', () => {
const label = 'Foo bar';
createComponent({ propsData: { label } });
expect(wrapper.findByRole('group', { name: label }).exists()).toBe(true);
});
});
});

View File

@ -1,6 +0,0 @@
export const groupNamespaces = [
{ id: 1, name: 'Group 1', humanName: 'Group 1' },
{ id: 2, name: 'Subgroup 1', humanName: 'Group 1 / Subgroup 1' },
];
export const userNamespaces = [{ id: 3, name: 'User namespace 1', humanName: 'User namespace 1' }];

View File

@ -1,236 +0,0 @@
import { nextTick } from 'vue';
import {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlIntersectionObserver,
GlLoadingIcon,
} from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import NamespaceSelect, {
i18n,
EMPTY_NAMESPACE_ID,
} from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue';
import { userNamespaces, groupNamespaces } from './mock_data';
const FLAT_NAMESPACES = [...userNamespaces, ...groupNamespaces];
const EMPTY_NAMESPACE_TITLE = 'Empty namespace TEST';
const EMPTY_NAMESPACE_ITEM = { id: EMPTY_NAMESPACE_ID, humanName: EMPTY_NAMESPACE_TITLE };
describe('NamespaceSelectDeprecated', () => {
let wrapper;
const createComponent = (props = {}) =>
shallowMountExtended(NamespaceSelect, {
propsData: {
userNamespaces,
groupNamespaces,
...props,
},
stubs: {
// We have to "full" mount GlDropdown so that slot children will render
GlDropdown,
},
});
const wrappersText = (arr) => arr.wrappers.map((w) => w.text());
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownText = () => findDropdown().props('text');
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findGroupDropdownItems = () =>
wrapper.findByTestId('namespace-list-groups').findAllComponents(GlDropdownItem);
const findDropdownItemsTexts = () => findDropdownItems().wrappers.map((x) => x.text());
const findSectionHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const search = (term) => findSearchBox().vm.$emit('input', term);
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the dropdown', () => {
expect(findDropdown().exists()).toBe(true);
});
it('renders each dropdown item', () => {
expect(findDropdownItemsTexts()).toEqual(FLAT_NAMESPACES.map((x) => x.humanName));
});
it('renders default dropdown text', () => {
expect(findDropdownText()).toBe(i18n.DEFAULT_TEXT);
});
it('splits group and user namespaces', () => {
const headers = findSectionHeaders();
expect(wrappersText(headers)).toEqual([i18n.USERS, i18n.GROUPS]);
});
it('does not render wrapper as full width', () => {
expect(findDropdown().attributes('block')).toBeUndefined();
});
});
it('with defaultText, it overrides dropdown text', () => {
const textOverride = 'Select an option';
wrapper = createComponent({ defaultText: textOverride });
expect(findDropdownText()).toBe(textOverride);
});
it('with includeHeaders=false, hides group/user headers', () => {
wrapper = createComponent({ includeHeaders: false });
expect(findSectionHeaders()).toHaveLength(0);
});
it('with fullWidth=true, sets the dropdown to full width', () => {
wrapper = createComponent({ fullWidth: true });
expect(findDropdown().attributes('block')).toBe('true');
});
describe('with search', () => {
it.each`
term | includeEmptyNamespace | shouldFilterNamespaces | expectedItems
${''} | ${false} | ${true} | ${[...userNamespaces, ...groupNamespaces]}
${'sub'} | ${false} | ${true} | ${[groupNamespaces[1]]}
${'User'} | ${false} | ${true} | ${[...userNamespaces]}
${'User'} | ${true} | ${true} | ${[...userNamespaces]}
${'namespace'} | ${true} | ${true} | ${[EMPTY_NAMESPACE_ITEM, ...userNamespaces]}
${'sub'} | ${false} | ${false} | ${[...userNamespaces, ...groupNamespaces]}
`(
'with term=$term, includeEmptyNamespace=$includeEmptyNamespace, and shouldFilterNamespaces=$shouldFilterNamespaces should show $expectedItems.length',
async ({ term, includeEmptyNamespace, shouldFilterNamespaces, expectedItems }) => {
wrapper = createComponent({
includeEmptyNamespace,
emptyNamespaceTitle: EMPTY_NAMESPACE_TITLE,
shouldFilterNamespaces,
});
search(term);
await nextTick();
const expected = expectedItems.map((x) => x.humanName);
expect(findDropdownItemsTexts()).toEqual(expected);
},
);
});
describe('when search is typed in', () => {
it('emits `search` event', async () => {
wrapper = createComponent();
wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'foo');
await nextTick();
expect(wrapper.emitted('search')).toEqual([['foo']]);
});
});
describe('with a selected namespace', () => {
const selectedGroupIndex = 1;
const selectedItem = groupNamespaces[selectedGroupIndex];
beforeEach(() => {
wrapper = createComponent();
wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'foo');
findGroupDropdownItems().at(selectedGroupIndex).vm.$emit('click');
});
it('sets the dropdown text', () => {
expect(findDropdownText()).toBe(selectedItem.humanName);
});
it('emits the `select` event when a namespace is selected', () => {
const args = [selectedItem];
expect(wrapper.emitted('select')).toEqual([args]);
});
it('clears search', () => {
expect(wrapper.findComponent(GlSearchBoxByType).props('value')).toBe('');
});
});
describe('with an empty namespace option', () => {
beforeEach(() => {
wrapper = createComponent({
includeEmptyNamespace: true,
emptyNamespaceTitle: EMPTY_NAMESPACE_TITLE,
});
});
it('includes the empty namespace', () => {
const first = findDropdownItems().at(0);
expect(first.text()).toBe(EMPTY_NAMESPACE_TITLE);
});
it('emits the `select` event when a namespace is selected', () => {
findDropdownItems().at(0).vm.$emit('click');
expect(wrapper.emitted('select')).toEqual([[EMPTY_NAMESPACE_ITEM]]);
});
it.each`
desc | term | shouldShow
${'should hide empty option'} | ${'group'} | ${false}
${'should show empty option'} | ${'Empty'} | ${true}
`('when search for $term, $desc', async ({ term, shouldShow }) => {
search(term);
await nextTick();
expect(findDropdownItemsTexts().includes(EMPTY_NAMESPACE_TITLE)).toBe(shouldShow);
});
});
describe('when `hasNextPageOfGroups` prop is `true`', () => {
it('renders `GlIntersectionObserver` and emits `load-more-groups` event when bottom is reached', () => {
wrapper = createComponent({ hasNextPageOfGroups: true });
const intersectionObserver = wrapper.findComponent(GlIntersectionObserver);
intersectionObserver.vm.$emit('appear');
expect(intersectionObserver.exists()).toBe(true);
expect(wrapper.emitted('load-more-groups')).toEqual([[]]);
});
describe('when `isLoading` prop is `true`', () => {
it('renders a loading icon', () => {
wrapper = createComponent({ hasNextPageOfGroups: true, isLoading: true });
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
});
});
describe('when `isSearchLoading` prop is `true`', () => {
it('sets `isLoading` prop to `true`', () => {
wrapper = createComponent({ isSearchLoading: true });
expect(wrapper.findComponent(GlSearchBoxByType).props('isLoading')).toBe(true);
});
});
describe('when dropdown is opened', () => {
it('emits `show` event', () => {
wrapper = createComponent();
findDropdown().vm.$emit('show');
expect(wrapper.emitted('show')).toEqual([[]]);
});
});
});

View File

@ -287,39 +287,6 @@ RSpec.describe GroupsHelper do
end
end
describe '#parent_group_options' do
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group, name: 'group') }
let_it_be(:group2) { create(:group, name: 'group2') }
before do
group.add_owner(current_user)
group2.add_owner(current_user)
end
it 'includes explicitly owned groups except self' do
expect(parent_group_options(group2)).to eq([{ id: group.id, text: group.human_name }].to_json)
end
it 'excludes parent group' do
subgroup = create(:group, parent: group2)
expect(parent_group_options(subgroup)).to eq([{ id: group.id, text: group.human_name }].to_json)
end
it 'includes subgroups with inherited ownership' do
subgroup = create(:group, parent: group)
expect(parent_group_options(group2)).to eq([{ id: group.id, text: group.human_name }, { id: subgroup.id, text: subgroup.human_name }].to_json)
end
it 'excludes own subgroups' do
create(:group, parent: group2)
expect(parent_group_options(group2)).to eq([{ id: group.id, text: group.human_name }].to_json)
end
end
describe '#can_disable_group_emails?' do
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group, name: 'group') }

View File

@ -100,16 +100,53 @@ RSpec.describe Gitlab::Ci::Parsers::Sbom::Cyclonedx do
]
end
before do
allow(report).to receive(:add_component)
end
it 'adds each component, ignoring unused attributes' do
expect(report).to receive(:add_component)
.with(an_object_having_attributes(name: "activesupport", version: "5.1.4", component_type: "library"))
.with(
an_object_having_attributes(
name: "activesupport",
version: "5.1.4",
component_type: "library",
purl: an_object_having_attributes(type: "gem")
)
)
expect(report).to receive(:add_component)
.with(an_object_having_attributes(name: "byebug", version: "10.0.0", component_type: "library"))
.with(
an_object_having_attributes(
name: "byebug",
version: "10.0.0",
component_type: "library",
purl: an_object_having_attributes(type: "gem")
)
)
expect(report).to receive(:add_component)
.with(an_object_having_attributes(name: "minimal-component", version: nil, component_type: "library"))
parse!
end
context 'when a component has an invalid purl' do
before do
components.push(
{
"name" => "invalid-component",
"version" => "v0.0.1",
"purl" => "pkg:nil",
"type" => "library"
}
)
end
it 'adds an error to the report' do
expect(report).to receive(:add_error).with("/components/#{components.size - 1}/purl is invalid")
parse!
end
end
end
context 'when report has metadata properties' do

View File

@ -1,23 +1,67 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require 'spec_helper'
RSpec.describe Gitlab::Ci::Reports::Sbom::Component do
let(:attributes) do
{
type: 'library',
name: 'component-name',
version: 'v0.0.1'
}
end
let(:component_type) { 'library' }
let(:name) { 'component-name' }
let(:purl_type) { 'npm' }
let(:purl) { Sbom::PackageUrl.new(type: purl_type, name: name, version: version).to_s }
let(:version) { 'v0.0.1' }
subject { described_class.new(**attributes) }
it 'has correct attributes' do
expect(subject).to have_attributes(
component_type: attributes[:type],
name: attributes[:name],
version: attributes[:version]
subject(:component) do
described_class.new(
type: component_type,
name: name,
purl: purl,
version: version
)
end
it 'has correct attributes' do
expect(component).to have_attributes(
component_type: component_type,
name: name,
purl: an_object_having_attributes(type: purl_type),
version: version
)
end
describe '#ingestible?' do
subject { component.ingestible? }
context 'when component_type is invalid' do
let(:component_type) { 'invalid' }
it { is_expected.to be(false) }
end
context 'when purl_type is invalid' do
let(:purl_type) { 'invalid' }
it { is_expected.to be(false) }
end
context 'when component_type is valid' do
where(:component_type) { ::Enums::Sbom.component_types.keys.map(&:to_s) }
with_them do
it { is_expected.to be(true) }
end
end
context 'when purl_type is valid' do
where(:purl_type) { ::Enums::Sbom.purl_types.keys.map(&:to_s) }
with_them do
it { is_expected.to be(true) }
end
end
context 'when there is no purl' do
let(:purl) { nil }
it { is_expected.to be(true) }
end
end
end

View File

@ -5,6 +5,21 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Reports::Sbom::Report do
subject(:report) { described_class.new }
describe '#valid?' do
context 'when there are no errors' do
it { is_expected.to be_valid }
end
context 'when report contains errors' do
before do
report.add_error('error1')
report.add_error('error2')
end
it { is_expected.not_to be_valid }
end
end
describe '#add_error' do
it 'appends errors to a list' do
report.add_error('error1')

View File

@ -33,12 +33,14 @@ RSpec.describe Sbom::PackageUrl::Decoder do
end
context 'when an invalid package URL string is passed' do
let(:url) { 'invalid' }
where(:url) { ['invalid', 'pkg:nil'] }
with_them do
it 'raises an error' do
expect { decode }.to raise_error(Sbom::PackageUrl::InvalidPackageURL)
end
end
end
context 'when namespace or subpath contains an encoded slash' do
where(:url) do