diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js index 48cf346d0e6..e859160c2e7 100644 --- a/app/assets/javascripts/api/groups_api.js +++ b/app/assets/javascripts/api/groups_api.js @@ -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 } }); +}; diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue index e28459811d7..15a193f7cb8 100644 --- a/app/assets/javascripts/groups/components/transfer_group_form.vue +++ b/app/assets/javascripts/groups/components/transfer_group_form.vue @@ -1,29 +1,24 @@ - - - - + + { - 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, diff --git a/app/assets/javascripts/groups_projects/components/transfer_locations.vue b/app/assets/javascripts/groups_projects/components/transfer_locations.vue index 11d7fa8d65b..e0c8ce36e3c 100644 --- a/app/assets/javascripts/groups_projects/components/transfer_locations.vue +++ b/app/assets/javascripts/groups_projects/components/transfer_locations.vue @@ -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 }} - - + + + + {{ item.humanName }} + + - {{ $options.i18n.GROUPS }} + {{ + $options.i18n.GROUPS + }} -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, -}; - - - - - - - - - {{ emptyNamespaceTitle }} - - - - - {{ - $options.i18n.USERS - }} - {{ item.humanName }} - - - {{ - $options.i18n.GROUPS - }} - {{ item.humanName }} - - - - - diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index be8707dcd50..bf20204cfd9 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -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 basic-list-stats; - display: flex; - align-items: center; - padding: $gl-padding-12 0; + @include gl-display-table-row; } - h2 { - font-size: $gl-font-size; - font-weight: $gl-font-weight-bold; - margin-bottom: 0; + .project-cell { + @include gl-display-table-cell; + @include gl-border-b; + @include gl-vertical-align-top; + @include gl-py-4; + } - @include media-breakpoint-up(sm) { - .namespace-name { - font-weight: $gl-font-weight-normal; - } + .project-row:last-of-type { + .project-cell { + @include gl-border-none; } } - .avatar-container { - flex: 0 0 auto; - align-self: flex-start; + + &.admin-projects, + &.group-settings-projects { + .project-row { + @include basic-list-stats; + + .description > p { + @include gl-mb-0; + } + } + + .controls { + @include gl-line-height-42; + } } .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; - } - } + max-width: 625px; p, .commit-row-message { + @include gl-mb-0; @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; - } + max-height: $gl-spacing-scale-8; } } .ci-status-link { - display: inline-block; - line-height: 17px; - vertical-align: middle; - - &:hover { - text-decoration: none; - } + @include gl-text-decoration-none; } - .controls { - @include media-breakpoint-down(xs) { - margin-top: $gl-padding-8; - } + &:not(.compact) { + .controls { + @include media-breakpoint-up(lg) { + @include gl-justify-content-start; + @include gl-pr-9; - @include media-breakpoint-up(sm) { - margin-top: 0; - } - - @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; + &:not(.with-pipeline-status) { + .icon-wrapper:first-of-type { + @include media-breakpoint-up(lg) { + @include gl-ml-7; + } + } } } } - &:not(.with-pipeline-status) { - .icon-wrapper:first-of-type { - @include media-breakpoint-up(lg) { - margin-left: $gl-padding-32; + .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; } } } diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 1612c161f01..b234669c6d3 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -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 diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 50890489de2..4448dd6caac 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -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 diff --git a/app/models/concerns/enums/sbom.rb b/app/models/concerns/enums/sbom.rb index 518efa669ad..8848c0c5555 100644 --- a/app/models/concerns/enums/sbom.rb +++ b/app/models/concerns/enums/sbom.rb @@ -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 diff --git a/app/views/admin/users/_projects.html.haml b/app/views/admin/users/_projects.html.haml index 3ccf3ef4f2a..2f77e83ac49 100644 --- a/app/views/admin/users/_projects.html.haml +++ b/app/views/admin/users/_projects.html.haml @@ -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 diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml index 7fe5a7a665b..e01d703206c 100644 --- a/app/views/groups/settings/_transfer.html.haml +++ b/app/views/groups/settings/_transfer.html.haml @@ -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') diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index c39dc561801..43cd2ee4c5b 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -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, diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 81e2e066bd3..908eb2428e8 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -8,102 +8,108 @@ - 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 - - if project.namespace && !skip_namespace - = project.namespace.human_name - \/ - %span.project-name< - = project.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) } - = visibility_level_icon(project.visibility_level) + %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 - = sprite_icon('scale', size: 14, css_class: 'gl-mr-2') - = project_license_name(project) + - if explore_projects_tab? && project_license_name(project) + %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) + - if !explore_projects_tab? && access&.nonzero? + -# haml-lint:disable UnnecessaryStringOutput + = ' ' # prevent haml from eating the space between elements + %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 !explore_projects_tab? + = 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 - = 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 - = markdown_field(project, :description) + - if show_last_commit_as_description + .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.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2 + = markdown_field(project, :description) - - if project.topics.any? - .gl-mt-2 - = render "shared/projects/topics", project: project.present(current_user: current_user) + - if project.topics.any? + .gl-mt-2 + = render "shared/projects/topics", project: project.present(current_user: current_user) - = render_if_exists 'shared/projects/removed', project: project + = 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 diff --git a/db/migrate/20221010201815_add_purl_type_to_sbom_components.rb b/db/migrate/20221010201815_add_purl_type_to_sbom_components.rb new file mode 100644 index 00000000000..3ab2aa262b1 --- /dev/null +++ b/db/migrate/20221010201815_add_purl_type_to_sbom_components.rb @@ -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 diff --git a/db/migrate/20221010202339_remove_unique_index_on_sbom_components_type_and_name.rb b/db/migrate/20221010202339_remove_unique_index_on_sbom_components_type_and_name.rb new file mode 100644 index 00000000000..fe092232ca6 --- /dev/null +++ b/db/migrate/20221010202339_remove_unique_index_on_sbom_components_type_and_name.rb @@ -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 diff --git a/db/migrate/20221010202408_add_unique_index_on_sbom_components_type_name_and_purl_type.rb b/db/migrate/20221010202408_add_unique_index_on_sbom_components_type_name_and_purl_type.rb new file mode 100644 index 00000000000..5935db7c2c1 --- /dev/null +++ b/db/migrate/20221010202408_add_unique_index_on_sbom_components_type_name_and_purl_type.rb @@ -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 diff --git a/db/schema_migrations/20221010201815 b/db/schema_migrations/20221010201815 new file mode 100644 index 00000000000..8c4c06ba4f6 --- /dev/null +++ b/db/schema_migrations/20221010201815 @@ -0,0 +1 @@ +f1f30c3581e35a92f3ede694e1eb70c6fc4dccfdb9e377b5f9046e18eaca2c54 \ No newline at end of file diff --git a/db/schema_migrations/20221010202339 b/db/schema_migrations/20221010202339 new file mode 100644 index 00000000000..c536fc8a3dc --- /dev/null +++ b/db/schema_migrations/20221010202339 @@ -0,0 +1 @@ +33bbeaa1d94cfa936de422fcc2f0456d235dde13072f6907cd514a12956ef9aa \ No newline at end of file diff --git a/db/schema_migrations/20221010202408 b/db/schema_migrations/20221010202408 new file mode 100644 index 00000000000..2007c27f7fd --- /dev/null +++ b/db/schema_migrations/20221010202408 @@ -0,0 +1 @@ +0e985bac7558768e0b97316c1362cb411fed5605c0a313c3872e86f7242f8d36 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index ad57dd2cd28..8c5d395e3f7 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -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); diff --git a/doc/api/commits.md b/doc/api/commits.md index 72ec73064dc..3fe77dd5f43 100644 --- a/doc/api/commits.md +++ b/doc/api/commits.md @@ -536,7 +536,7 @@ POST /projects/:id/repository/commits/:sha/comments ```shell curl --request POST --header "PRIVATE-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!" } ``` diff --git a/doc/api/repositories.md b/doc/api/repositories.md index 751bbd75c7a..428a09f1bbe 100644 --- a/doc/api/repositories.md +++ b/doc/api/repositories.md @@ -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" } ``` diff --git a/doc/development/documentation/restful_api_styleguide.md b/doc/development/documentation/restful_api_styleguide.md index 4be7055b45f..21c8c8543ab 100644 --- a/doc/development/documentation/restful_api_styleguide.md +++ b/doc/development/documentation/restful_api_styleguide.md @@ -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: " "https://gitlab.example.com/api/v4/projects/42/issues?title=Hello%20Dude" +curl --request POST --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/projects/42/issues?title=Hello%20GitLab" ``` Use `%2F` for slashes (`/`). diff --git a/doc/development/documentation/styleguide/index.md b/doc/development/documentation/styleguide/index.md index 7e83eee2760..ef934186981 100644 --- a/doc/development/documentation/styleguide/index.md +++ b/doc/development/documentation/styleguide/index.md @@ -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) diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb index e299f49042d..954b572c9b1 100644 --- a/lib/api/commit_statuses.rb +++ b/lib/api/commit_statuses.rb @@ -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' } diff --git a/lib/api/entities/commit_status.rb b/lib/api/entities/commit_status.rb index 21c1512edfd..df6a41895ff 100644 --- a/lib/api/entities/commit_status.rb +++ b/lib/api/entities/commit_status.rb @@ -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' } diff --git a/lib/gitlab/ci/parsers/sbom/cyclonedx.rb b/lib/gitlab/ci/parsers/sbom/cyclonedx.rb index aa594ca4049..f1a07af1bf9 100644 --- a/lib/gitlab/ci/parsers/sbom/cyclonedx.rb +++ b/lib/gitlab/ci/parsers/sbom/cyclonedx.rb @@ -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 diff --git a/lib/gitlab/ci/reports/sbom/component.rb b/lib/gitlab/ci/reports/sbom/component.rb index 198b34451b4..5188304f4ed 100644 --- a/lib/gitlab/ci/reports/sbom/component.rb +++ b/lib/gitlab/ci/reports/sbom/component.rb @@ -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 diff --git a/lib/gitlab/ci/reports/sbom/report.rb b/lib/gitlab/ci/reports/sbom/report.rb index 4f84d12f78c..51fa8ce0d2e 100644 --- a/lib/gitlab/ci/reports/sbom/report.rb +++ b/lib/gitlab/ci/reports/sbom/report.rb @@ -12,6 +12,10 @@ module Gitlab @errors = [] end + def valid? + errors.empty? + end + def add_error(error) errors << error end diff --git a/lib/sbom/package_url.rb b/lib/sbom/package_url.rb index 6afd9943992..3b545ebebf2 100644 --- a/lib/sbom/package_url.rb +++ b/lib/sbom/package_url.rb @@ -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 diff --git a/lib/sbom/package_url/decoder.rb b/lib/sbom/package_url/decoder.rb index a8032e021c5..5a31343995d 100644 --- a/lib/sbom/package_url/decoder.rb +++ b/lib/sbom/package_url/decoder.rb @@ -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) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 89c6f4b9334..bdb826d8209 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -27216,9 +27216,6 @@ msgstr "" msgid "No milestone" msgstr "" -msgid "No namespace" -msgstr "" - msgid "No other labels with such name or description" msgstr "" diff --git a/qa/qa/page/component/namespace_select.rb b/qa/qa/page/component/namespace_select.rb index 392900255db..095a57b1156 100644 --- a/qa/qa/page/component/namespace_select.rb +++ b/qa/qa/page/component/namespace_select.rb @@ -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 diff --git a/scripts/rubocop-parse b/scripts/rubocop-parse index 0a234df81cd..c99d66e99ad 100755 --- a/scripts/rubocop-parse +++ b/scripts/rubocop-parse @@ -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 diff --git a/spec/factories/ci/reports/sbom/components.rb b/spec/factories/ci/reports/sbom/components.rb index fd9b4386130..8f2c00b695a 100644 --- a/spec/factories/ci/reports/sbom/components.rb +++ b/spec/factories/ci/reports/sbom/components.rb @@ -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 diff --git a/spec/factories/ci/reports/sbom/reports.rb b/spec/factories/ci/reports/sbom/reports.rb index 4a83b5898ef..7a076282915 100644 --- a/spec/factories/ci/reports/sbom/reports.rb +++ b/spec/factories/ci/reports/sbom/reports.rb @@ -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) diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index 306888b9ab8..c132caa88c8 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -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') diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb index 2f599d24b01..81ff0088e1e 100644 --- a/spec/features/groups/group_settings_spec.rb +++ b/spec/features/groups/group_settings_spec.rb @@ -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,14 +192,11 @@ 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') } + let(:target_group_name) { group.name } - context 'to a parent group' do - let(:target_group_name) { group.name } - - it_behaves_like 'can transfer the group' - end + it_behaves_like 'can transfer the group' end end diff --git a/spec/frontend/api/groups_api_spec.js b/spec/frontend/api/groups_api_spec.js index e14ead0b8eb..9de588a02aa 100644 --- a/spec/frontend/api/groups_api_spec.js +++ b/spec/frontend/api/groups_api_spec.js @@ -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 }, + }); + }); + }); }); diff --git a/spec/frontend/fixtures/namespaces.rb b/spec/frontend/fixtures/namespaces.rb index a3f295f4e66..9858e3241cb 100644 --- a/spec/frontend/fixtures/namespaces.rb +++ b/spec/frontend/fixtures/namespaces.rb @@ -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) } diff --git a/spec/frontend/groups/components/transfer_group_form_spec.js b/spec/frontend/groups/components/transfer_group_form_spec.js index 7cbe6e5bbab..0065820f78f 100644 --- a/spec/frontend/groups/components/transfer_group_form_spec.js +++ b/spec/frontend/groups/components/transfer_group_form_spec.js @@ -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); }); }); }); diff --git a/spec/frontend/groups_projects/components/transfer_locations_spec.js b/spec/frontend/groups_projects/components/transfer_locations_spec.js index c3308caf4b0..74424ee3230 100644 --- a/spec/frontend/groups_projects/components/transfer_locations_spec.js +++ b/spec/frontend/groups_projects/components/transfer_locations_spec.js @@ -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); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/namespace_select/mock_data.js b/spec/frontend/vue_shared/components/namespace_select/mock_data.js deleted file mode 100644 index cfd521c67cb..00000000000 --- a/spec/frontend/vue_shared/components/namespace_select/mock_data.js +++ /dev/null @@ -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' }]; diff --git a/spec/frontend/vue_shared/components/namespace_select/namespace_select_deprecated_spec.js b/spec/frontend/vue_shared/components/namespace_select/namespace_select_deprecated_spec.js deleted file mode 100644 index d930ef63dad..00000000000 --- a/spec/frontend/vue_shared/components/namespace_select/namespace_select_deprecated_spec.js +++ /dev/null @@ -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([[]]); - }); - }); -}); diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index a38483a956d..aebf41d1333 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -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') } diff --git a/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb b/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb index f3636106b98..0b094880f69 100644 --- a/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb +++ b/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb @@ -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 diff --git a/spec/lib/gitlab/ci/reports/sbom/component_spec.rb b/spec/lib/gitlab/ci/reports/sbom/component_spec.rb index 06ea3433ef0..cdaf9354104 100644 --- a/spec/lib/gitlab/ci/reports/sbom/component_spec.rb +++ b/spec/lib/gitlab/ci/reports/sbom/component_spec.rb @@ -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 diff --git a/spec/lib/gitlab/ci/reports/sbom/report_spec.rb b/spec/lib/gitlab/ci/reports/sbom/report_spec.rb index 6ffa93e5fc8..f9a83378f46 100644 --- a/spec/lib/gitlab/ci/reports/sbom/report_spec.rb +++ b/spec/lib/gitlab/ci/reports/sbom/report_spec.rb @@ -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') diff --git a/spec/lib/sbom/package_url/decoder_spec.rb b/spec/lib/sbom/package_url/decoder_spec.rb index 6f709c93601..1da3c35f403 100644 --- a/spec/lib/sbom/package_url/decoder_spec.rb +++ b/spec/lib/sbom/package_url/decoder_spec.rb @@ -33,10 +33,12 @@ 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'] } - it 'raises an error' do - expect { decode }.to raise_error(Sbom::PackageUrl::InvalidPackageURL) + with_them do + it 'raises an error' do + expect { decode }.to raise_error(Sbom::PackageUrl::InvalidPackageURL) + end end end