Commit 7797cff0 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 0e78cc64 58ea5d5d
<script> <script>
import { GlAvatarLink, GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { truncate } from '~/lib/utils/text_utility'; import { truncate } from '~/lib/utils/text_utility';
import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from '../constants'; import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from '../constants';
...@@ -8,7 +8,6 @@ export default { ...@@ -8,7 +8,6 @@ export default {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
components: { components: {
GlAvatarLink,
GlAvatarLabeled, GlAvatarLabeled,
GlBadge, GlBadge,
GlIcon, GlIcon,
...@@ -27,6 +26,11 @@ export default { ...@@ -27,6 +26,11 @@ export default {
adminUserHref() { adminUserHref() {
return this.adminUserPath.replace('id', this.user.username); return this.adminUserPath.replace('id', this.user.username);
}, },
adminUserMailto() {
// NOTE: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
// eslint-disable-next-line @gitlab/require-i18n-strings
return `mailto:${this.user.email}`;
},
userNoteShort() { userNoteShort() {
return truncate(this.user.note, LENGTH_OF_USER_NOTE_TOOLTIP); return truncate(this.user.note, LENGTH_OF_USER_NOTE_TOOLTIP);
}, },
...@@ -36,10 +40,9 @@ export default { ...@@ -36,10 +40,9 @@ export default {
</script> </script>
<template> <template>
<gl-avatar-link <div
v-if="user" v-if="user"
class="js-user-link" class="js-user-link gl-display-inline-block"
:href="adminUserHref"
:data-user-id="user.id" :data-user-id="user.id"
:data-username="user.username" :data-username="user.username"
> >
...@@ -48,6 +51,8 @@ export default { ...@@ -48,6 +51,8 @@ export default {
:src="user.avatarUrl" :src="user.avatarUrl"
:label="user.name" :label="user.name"
:sub-label="user.email" :sub-label="user.email"
:label-link="adminUserHref"
:sub-label-link="adminUserMailto"
> >
<template #meta> <template #meta>
<div v-if="user.note" class="gl-text-gray-500 gl-p-1"> <div v-if="user.note" class="gl-text-gray-500 gl-p-1">
...@@ -60,5 +65,5 @@ export default { ...@@ -60,5 +65,5 @@ export default {
</div> </div>
</template> </template>
</gl-avatar-labeled> </gl-avatar-labeled>
</gl-avatar-link> </div>
</template> </template>
...@@ -13,8 +13,8 @@ export default { ...@@ -13,8 +13,8 @@ export default {
</script> </script>
<template> <template>
<span class="gl-ml-4"> <span class="gl-ml-3 gl-display-flex gl-align-items-center">
<gl-button variant="success" @click="setAddColumnFormVisibility(true)" <gl-button variant="confirm" @click="setAddColumnFormVisibility(true)"
>{{ __('Create list') }} >{{ __('Create list') }}
</gl-button> </gl-button>
</span> </span>
......
...@@ -10,7 +10,6 @@ import { ...@@ -10,7 +10,6 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { BV_DROPDOWN_HIDE } from '~/lib/utils/constants';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import projectMilestones from '../../graphql/project_milestones.query.graphql'; import projectMilestones from '../../graphql/project_milestones.query.graphql';
...@@ -73,21 +72,20 @@ export default { ...@@ -73,21 +72,20 @@ export default {
return this.activeIssue.milestone?.title ?? this.$options.i18n.noMilestone; return this.activeIssue.milestone?.title ?? this.$options.i18n.noMilestone;
}, },
}, },
mounted() {
this.$root.$on(BV_DROPDOWN_HIDE, () => {
this.$refs.sidebarItem.collapse();
});
},
methods: { methods: {
...mapActions(['setActiveIssueMilestone']), ...mapActions(['setActiveIssueMilestone']),
handleOpen() { handleOpen() {
this.edit = true; this.edit = true;
this.$refs.dropdown.show(); this.$refs.dropdown.show();
}, },
handleClose() {
this.edit = false;
this.$refs.sidebarItem.collapse();
},
async setMilestone(milestoneId) { async setMilestone(milestoneId) {
this.loading = true; this.loading = true;
this.searchTitle = ''; this.searchTitle = '';
this.$refs.sidebarItem.collapse(); this.handleClose();
try { try {
const input = { milestoneId, projectPath: this.projectPath }; const input = { milestoneId, projectPath: this.projectPath };
...@@ -116,7 +114,7 @@ export default { ...@@ -116,7 +114,7 @@ export default {
:title="$options.i18n.milestone" :title="$options.i18n.milestone"
:loading="loading" :loading="loading"
@open="handleOpen()" @open="handleOpen()"
@close="edit = false" @close="handleClose"
> >
<template v-if="hasMilestone" #collapsed> <template v-if="hasMilestone" #collapsed>
<strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong> <strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong>
...@@ -126,6 +124,7 @@ export default { ...@@ -126,6 +124,7 @@ export default {
:text="dropdownText" :text="dropdownText"
:header-text="$options.i18n.assignMilestone" :header-text="$options.i18n.assignMilestone"
block block
@hide="handleClose"
> >
<gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" /> <gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" />
<gl-dropdown-item <gl-dropdown-item
......
<script> <script>
import { GlIcon } from '@gitlab/ui'; import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { hide } from '~/tooltips'; import { hide } from '~/tooltips';
export default { export default {
components: { components: {
GlIcon, GlButton,
},
directives: {
GlTooltip,
}, },
props: { props: {
issueBoardsContentSelector: { issueBoardsContentSelector: {
...@@ -35,18 +38,15 @@ export default { ...@@ -35,18 +38,15 @@ export default {
</script> </script>
<template> <template>
<div class="board-extra-actions"> <div class="board-extra-actions gl-ml-3 gl-display-flex gl-align-items-center">
<a <gl-button
ref="toggleFocusModeButton" ref="toggleFocusModeButton"
href="#" v-gl-tooltip
class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn" :icon="isFullscreen ? 'minimize' : 'maximize'"
class="js-focus-mode-btn"
data-qa-selector="focus_mode_button" data-qa-selector="focus_mode_button"
role="button"
:aria-label="$options.i18n.toggleFocusMode"
:title="$options.i18n.toggleFocusMode" :title="$options.i18n.toggleFocusMode"
@click="toggleFocusMode" @click="toggleFocusMode"
> />
<gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" />
</a>
</div> </div>
</template> </template>
...@@ -2,8 +2,7 @@ ...@@ -2,8 +2,7 @@
import $ from 'jquery'; import $ from 'jquery';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import { dispose } from '~/tooltips';
import { hide, dispose } from '~/tooltips';
import { deprecatedCreateFlash as flash } from './flash'; import { deprecatedCreateFlash as flash } from './flash';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import { __ } from './locale'; import { __ } from './locale';
...@@ -30,7 +29,6 @@ export default class LabelManager { ...@@ -30,7 +29,6 @@ export default class LabelManager {
} }
bindEvents() { bindEvents() {
this.prioritizedLabels.find('.btn-action').on('mousedown', this, this.onButtonActionClick);
return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick); return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick);
} }
...@@ -46,11 +44,6 @@ export default class LabelManager { ...@@ -46,11 +44,6 @@ export default class LabelManager {
_this.toggleEmptyState($label, $btn, action); _this.toggleEmptyState($label, $btn, action);
} }
onButtonActionClick(e) {
e.stopPropagation();
hide(e.currentTarget);
}
toggleEmptyState() { toggleEmptyState() {
this.emptyState.classList.toggle( this.emptyState.classList.toggle(
'hidden', 'hidden',
......
...@@ -12,25 +12,23 @@ import initSearchSettings from '~/search_settings'; ...@@ -12,25 +12,23 @@ import initSearchSettings from '~/search_settings';
import initProjectPermissionsSettings from '../shared/permissions'; import initProjectPermissionsSettings from '../shared/permissions';
import initProjectLoadingSpinner from '../shared/save_project_loader'; import initProjectLoadingSpinner from '../shared/save_project_loader';
document.addEventListener('DOMContentLoaded', () => { initFilePickers();
initFilePickers(); initConfirmDangerModal();
initConfirmDangerModal(); initSettingsPanels();
initSettingsPanels(); initProjectDeleteButton();
initProjectDeleteButton(); mountBadgeSettings(PROJECT_BADGE);
mountBadgeSettings(PROJECT_BADGE);
new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new
initServiceDesk(); initServiceDesk();
initProjectLoadingSpinner(); initProjectLoadingSpinner();
initProjectPermissionsSettings(); initProjectPermissionsSettings();
setupTransferEdit('.js-project-transfer-form', 'select.select2'); setupTransferEdit('.js-project-transfer-form', 'select.select2');
dirtySubmitFactory( dirtySubmitFactory(
document.querySelectorAll( document.querySelectorAll(
'.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form', '.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form',
), ),
); );
initSearchSettings(); initSearchSettings();
});
import initEnvironments from '~/environments/'; import initEnvironments from '~/environments/';
document.addEventListener('DOMContentLoaded', initEnvironments); initEnvironments();
...@@ -280,7 +280,7 @@ module ApplicationHelper ...@@ -280,7 +280,7 @@ module ApplicationHelper
def page_class def page_class
class_names = [] class_names = []
class_names << 'issue-boards-page' if current_controller?(:boards) class_names << 'issue-boards-page gl-overflow-hidden' if current_controller?(:boards)
class_names << 'environment-logs-page' if current_controller?(:logs) class_names << 'environment-logs-page' if current_controller?(:logs)
class_names << 'with-performance-bar' if performance_bar_enabled? class_names << 'with-performance-bar' if performance_bar_enabled?
class_names << system_message_class class_names << system_message_class
......
...@@ -228,17 +228,6 @@ module DesignManagement ...@@ -228,17 +228,6 @@ module DesignManagement
project project
end end
def immediately_before?(next_design)
return false if next_design.relative_position <= relative_position
interloper = self.class.on_issue(issue).where(
"relative_position <@ int4range(?, ?, '()')",
*[self, next_design].map(&:relative_position)
)
!interloper.exists?
end
def notes_with_associations def notes_with_associations
notes.includes(:author) notes.includes(:author)
end end
......
...@@ -16,7 +16,6 @@ module DesignManagement ...@@ -16,7 +16,6 @@ module DesignManagement
return error(:cannot_move) unless current_user.can?(:move_design, current_design) return error(:cannot_move) unless current_user.can?(:move_design, current_design)
return error(:no_neighbors) unless neighbors.present? return error(:no_neighbors) unless neighbors.present?
return error(:not_distinct) unless all_distinct? return error(:not_distinct) unless all_distinct?
return error(:not_adjacent) if any_in_gap?
return error(:not_same_issue) unless all_same_issue? return error(:not_same_issue) unless all_same_issue?
move_nulls_to_end move_nulls_to_end
...@@ -54,12 +53,6 @@ module DesignManagement ...@@ -54,12 +53,6 @@ module DesignManagement
ids.uniq.size == ids.size ids.uniq.size == ids.size
end end
def any_in_gap?
return false unless previous_design&.relative_position && next_design&.relative_position
!previous_design.immediately_before?(next_design)
end
def all_same_issue? def all_same_issue?
issue.designs.id_in(ids).count == ids.size issue.designs.id_in(ids).count == ids.size
end end
......
...@@ -101,8 +101,30 @@ module MergeRequests ...@@ -101,8 +101,30 @@ module MergeRequests
%w(title description).each do |action| %w(title description).each do |action|
next unless @issuable_changes.key?(action) next unless @issuable_changes.key?(action)
Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter # Track edits to title or description
#
merge_request_activity_counter
.public_send("track_#{action}_edit_action".to_sym, user: current_user) # rubocop:disable GitlabSecurity/PublicSend .public_send("track_#{action}_edit_action".to_sym, user: current_user) # rubocop:disable GitlabSecurity/PublicSend
# Track changes to Draft/WIP status
#
if action == "title"
old_title, new_title = @issuable_changes["title"]
old_title_wip = MergeRequest.work_in_progress?(old_title)
new_title_wip = MergeRequest.work_in_progress?(new_title)
if !old_title_wip && new_title_wip
# Marked as Draft/WIP
#
merge_request_activity_counter
.track_marked_as_draft_action(user: current_user)
elsif old_title_wip && !new_title_wip
# Unmarked as Draft/WIP
#
merge_request_activity_counter
.track_unmarked_as_draft_action(user: current_user)
end
end
end end
end end
......
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
%ul %ul
- if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group) - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
%li %li
%button.js-promote-project-label-button.btn.btn-transparent.btn-action{ disabled: true, type: 'button', %button.js-promote-project-label-button.btn.btn-transparent{ disabled: true, type: 'button',
data: { url: promote_project_label_path(label.project, label), data: { url: promote_project_label_path(label.project, label),
label_title: label.title, label_title: label.title,
label_color: label.color, label_color: label.color,
......
.dropdown.gl-ml-3#js-add-list .dropdown.gl-display-flex.gl-align-items-center.gl-ml-3#js-add-list
%button.gl-button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data } %button.gl-button.btn.btn-confirm.btn-confirm-secondary.js-new-board-list{ type: "button", data: board_list_data }
Add list Add list
.dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels .dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" } = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
......
...@@ -193,6 +193,8 @@ ...@@ -193,6 +193,8 @@
.filter-dropdown-container.d-flex.flex-column.flex-md-row .filter-dropdown-container.d-flex.flex-column.flex-md-row
- if type == :boards - if type == :boards
#js-board-labels-toggle #js-board-labels-toggle
- if current_user
#js-board-epics-swimlanes-toggle
.js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } } .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } }
- if user_can_admin_list - if user_can_admin_list
- if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml) - if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml)
...@@ -200,9 +202,7 @@ ...@@ -200,9 +202,7 @@
- else - else
= render 'shared/issuable/board_create_list_dropdown', board: board = render 'shared/issuable/board_create_list_dropdown', board: board
- if @project - if @project
#js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } #js-add-issues-btn{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- if current_user
#js-board-epics-swimlanes-toggle
#js-toggle-focus-btn #js-toggle-focus-btn
- elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown - elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown
= render 'shared/issuable/sort_dropdown' = render 'shared/issuable/sort_dropdown'
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
= markdown_field(label, :description) = markdown_field(label, :description)
.float-right.d-none.d-lg-block .float-right.d-none.d-lg-block
= link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn gl-button btn-default-tertiary btn-action' do = link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn gl-button btn-default-tertiary' do
- pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), _('open issue') - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), _('open issue')
= link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn gl-button btn-default-tertiary btn-action' do = link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn gl-button btn-default-tertiary' do
- pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), _('closed issue') - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), _('closed issue')
---
title: Allow running Puma in Single mode
merge_request: 53830
author:
type: other
---
title: Fix spurious not-adjacent error when moving designs
merge_request: 53771
author:
type: fixed
---
title: Align and reorder boards search bar buttons
merge_request: 53690
author:
type: changed
---
name: usage_data_i_code_review_user_marked_as_draft
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/301223
rollout_issue_url:
milestone: '13.9'
type: development
group: group::code review
default_enabled: true
---
name: usage_data_i_code_review_user_unmarked_as_draft
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/301223
rollout_issue_url:
milestone: '13.9'
type: development
group: group::code review
default_enabled: true
# frozen_string_literal: true # frozen_string_literal: true
def allow_single_mode? def max_puma_workers
return false if Gitlab.com? Puma.cli_config.options[:workers].to_i
Gitlab::Utils.to_boolean(ENV['PUMA_SKIP_CLUSTER_VALIDATION'])
end end
if Gitlab::Runtime.puma? && ::Puma.cli_config.options[:workers].to_i == 0 if Gitlab::Runtime.puma? && max_puma_workers == 0
return if allow_single_mode? raise 'Puma is only supported in Clustered mode (workers > 0)' if Gitlab.com?
raise 'Puma is only supported in Cluster-mode: workers > 0' warn 'WARNING: Puma is running in Single mode (workers = 0). Some features may not work. Please refer to https://gitlab.com/groups/gitlab-org/-/epics/5303 for info.'
end end
...@@ -5902,6 +5902,46 @@ Identifier of Dast::Profile. ...@@ -5902,6 +5902,46 @@ Identifier of Dast::Profile.
""" """
scalar DastProfileID scalar DastProfileID
"""
Autogenerated input type of DastProfileRun
"""
input DastProfileRunInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Full path for the project the scanner profile belongs to.
"""
fullPath: ID!
"""
ID of the profile to be used for the scan.
"""
id: DastProfileID!
}
"""
Autogenerated return type of DastProfileRun
"""
type DastProfileRunPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
URL of the pipeline that was created.
"""
pipelineUrl: String
}
enum DastScanTypeEnum { enum DastScanTypeEnum {
""" """
Active DAST scan. This scan will make active attacks against the target site. Active DAST scan. This scan will make active attacks against the target site.
...@@ -16399,6 +16439,7 @@ type Mutation { ...@@ -16399,6 +16439,7 @@ type Mutation {
dastOnDemandScanCreate(input: DastOnDemandScanCreateInput!): DastOnDemandScanCreatePayload dastOnDemandScanCreate(input: DastOnDemandScanCreateInput!): DastOnDemandScanCreatePayload
dastProfileCreate(input: DastProfileCreateInput!): DastProfileCreatePayload dastProfileCreate(input: DastProfileCreateInput!): DastProfileCreatePayload
dastProfileDelete(input: DastProfileDeleteInput!): DastProfileDeletePayload dastProfileDelete(input: DastProfileDeleteInput!): DastProfileDeletePayload
dastProfileRun(input: DastProfileRunInput!): DastProfileRunPayload
dastScannerProfileCreate(input: DastScannerProfileCreateInput!): DastScannerProfileCreatePayload dastScannerProfileCreate(input: DastScannerProfileCreateInput!): DastScannerProfileCreatePayload
dastScannerProfileDelete(input: DastScannerProfileDeleteInput!): DastScannerProfileDeletePayload dastScannerProfileDelete(input: DastScannerProfileDeleteInput!): DastScannerProfileDeletePayload
dastScannerProfileUpdate(input: DastScannerProfileUpdateInput!): DastScannerProfileUpdatePayload dastScannerProfileUpdate(input: DastScannerProfileUpdateInput!): DastScannerProfileUpdatePayload
......
...@@ -16059,6 +16059,122 @@ ...@@ -16059,6 +16059,122 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INPUT_OBJECT",
"name": "DastProfileRunInput",
"description": "Autogenerated input type of DastProfileRun",
"fields": null,
"inputFields": [
{
"name": "fullPath",
"description": "Full path for the project the scanner profile belongs to.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "id",
"description": "ID of the profile to be used for the scan.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DastProfileID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DastProfileRunPayload",
"description": "Autogenerated return type of DastProfileRun",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipelineUrl",
"description": "URL of the pipeline that was created.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "ENUM", "kind": "ENUM",
"name": "DastScanTypeEnum", "name": "DastScanTypeEnum",
...@@ -45855,6 +45971,33 @@ ...@@ -45855,6 +45971,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "dastProfileRun",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "DastProfileRunInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DastProfileRunPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "dastScannerProfileCreate", "name": "dastScannerProfileCreate",
"description": null, "description": null,
...@@ -952,6 +952,16 @@ Autogenerated return type of DastProfileDelete. ...@@ -952,6 +952,16 @@ Autogenerated return type of DastProfileDelete.
| `clientMutationId` | String | A unique identifier for the client performing the mutation. | | `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. | | `errors` | String! => Array | Errors encountered during execution of the mutation. |
### DastProfileRunPayload
Autogenerated return type of DastProfileRun.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `pipelineUrl` | String | URL of the pipeline that was created. |
### DastScannerProfile ### DastScannerProfile
Represents a DAST scanner profile. Represents a DAST scanner profile.
......
...@@ -49,7 +49,7 @@ export default { ...@@ -49,7 +49,7 @@ export default {
</script> </script>
<template> <template>
<div class="gl-ml-3"> <div class="gl-ml-3 gl-display-flex gl-align-items-center">
<gl-button <gl-button
v-gl-modal-directive="'board-config-modal'" v-gl-modal-directive="'board-config-modal'"
v-gl-tooltip v-gl-tooltip
......
...@@ -64,15 +64,25 @@ export default { ...@@ -64,15 +64,25 @@ export default {
}, },
methods: { methods: {
...mapActions(['setActiveIssueEpic', 'fetchEpicForActiveIssue']), ...mapActions(['setActiveIssueEpic', 'fetchEpicForActiveIssue']),
openEpicsDropdown() { handleOpen() {
if (!this.loading) { if (!this.epicFetchInProgress) {
this.$refs.epicSelect.handleEditClick(); this.$refs.epicSelect.toggleFormDropdown();
} else {
this.$refs.sidebarItem.collapse();
} }
}, },
async setEpic(selectedEpic) { handleClose() {
this.$refs.sidebarItem.collapse(); this.$refs.sidebarItem.collapse();
this.$refs.epicSelect.toggleFormDropdown();
},
async setEpic(selectedEpic) {
this.handleClose();
const epicId = selectedEpic?.id ? fullEpicId(selectedEpic.id) : null; const epicId = selectedEpic?.id ? fullEpicId(selectedEpic.id) : null;
const assignedEpicId = this.epic?.id ? fullEpicId(this.epic.id) : null;
if (epicId === assignedEpicId) {
return;
}
try { try {
await this.setActiveIssueEpic(epicId); await this.setActiveIssueEpic(epicId);
...@@ -89,7 +99,8 @@ export default { ...@@ -89,7 +99,8 @@ export default {
ref="sidebarItem" ref="sidebarItem"
:title="$options.i18n.epic" :title="$options.i18n.epic"
:loading="epicFetchInProgress" :loading="epicFetchInProgress"
@open="openEpicsDropdown" @open="handleOpen"
@close="handleClose"
> >
<template v-if="epicData.title" #collapsed> <template v-if="epicData.title" #collapsed>
<a class="gl-text-gray-900! gl-font-weight-bold" href="#"> <a class="gl-text-gray-900! gl-font-weight-bold" href="#">
...@@ -107,6 +118,7 @@ export default { ...@@ -107,6 +118,7 @@ export default {
variant="standalone" variant="standalone"
:show-header="false" :show-header="false"
@epicSelect="setEpic" @epicSelect="setEpic"
@hide="handleClose"
/> />
</board-editable-item> </board-editable-item>
</template> </template>
...@@ -54,16 +54,12 @@ export default { ...@@ -54,16 +54,12 @@ export default {
data-testid="toggle-swimlanes" data-testid="toggle-swimlanes"
> >
<span <span
class="board-swimlanes-toggle-text gl-white-space-nowrap gl-font-weight-bold" class="board-swimlanes-toggle-text gl-white-space-nowrap gl-font-weight-bold gl-line-height-normal"
data-testid="toggle-swimlanes-label" data-testid="toggle-swimlanes-label"
> >
{{ __('Group by') }} {{ __('Group by') }}
</span> </span>
<gl-dropdown <gl-dropdown right :text="dropdownLabel" class="gl-ml-3" toggle-class="gl-line-height-normal!">
right
:text="dropdownLabel"
toggle-class="gl-ml-3 gl-border-none gl-inset-border-1-gray-200! border-radius-default"
>
<gl-dropdown-item <gl-dropdown-item
:is-check-item="true" :is-check-item="true"
:is-checked="!isShowingEpicsSwimlanes" :is-checked="!isShowingEpicsSwimlanes"
......
...@@ -20,6 +20,8 @@ import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site ...@@ -20,6 +20,8 @@ import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site
import { initFormField } from 'ee/security_configuration/utils'; import { initFormField } from 'ee/security_configuration/utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import validation from '~/vue_shared/directives/validation'; import validation from '~/vue_shared/directives/validation';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { serializeFormObject } from '~/lib/utils/forms';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { redirectTo, queryToObject } from '~/lib/utils/url_utility'; import { redirectTo, queryToObject } from '~/lib/utils/url_utility';
...@@ -42,6 +44,8 @@ import ProfileSelectorSummaryCell from './profile_selector/summary_cell.vue'; ...@@ -42,6 +44,8 @@ import ProfileSelectorSummaryCell from './profile_selector/summary_cell.vue';
import ScannerProfileSelector from './profile_selector/scanner_profile_selector.vue'; import ScannerProfileSelector from './profile_selector/scanner_profile_selector.vue';
import SiteProfileSelector from './profile_selector/site_profile_selector.vue'; import SiteProfileSelector from './profile_selector/site_profile_selector.vue';
export const ON_DEMAND_SCANS_STORAGE_KEY = 'on-demand-scans-new-form';
const createProfilesApolloOptions = (name, field, { fetchQuery, fetchError }) => ({ const createProfilesApolloOptions = (name, field, { fetchQuery, fetchError }) => ({
query: fetchQuery, query: fetchQuery,
variables() { variables() {
...@@ -80,6 +84,7 @@ export default { ...@@ -80,6 +84,7 @@ export default {
GlLink, GlLink,
GlSkeletonLoader, GlSkeletonLoader,
GlSprintf, GlSprintf,
LocalStorageSync,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -157,6 +162,7 @@ export default { ...@@ -157,6 +162,7 @@ export default {
errorType: null, errorType: null,
errors: [], errors: [],
showAlert: false, showAlert: false,
clearStorage: false,
}; };
}, },
computed: { computed: {
...@@ -229,6 +235,14 @@ export default { ...@@ -229,6 +235,14 @@ export default {
} = this; } = this;
return isFormInvalid || (loading && loading !== saveScanBtnId); return isFormInvalid || (loading && loading !== saveScanBtnId);
}, },
formFieldValues() {
const { selectedScannerProfileId, selectedSiteProfileId } = this;
return {
...serializeFormObject(this.form.fields),
selectedScannerProfileId,
selectedSiteProfileId,
};
},
}, },
created() { created() {
const params = queryToObject(window.location.search); const params = queryToObject(window.location.search);
...@@ -264,8 +278,7 @@ export default { ...@@ -264,8 +278,7 @@ export default {
input = { input = {
...input, ...input,
...(this.isEdit ? { id: this.dastScan.id } : {}), ...(this.isEdit ? { id: this.dastScan.id } : {}),
name: this.form.fields.name.value, ...serializeFormObject(this.form.fields),
description: this.form.fields.description.value,
runAfterCreate, runAfterCreate,
}; };
} }
...@@ -285,7 +298,9 @@ export default { ...@@ -285,7 +298,9 @@ export default {
this.loading = false; this.loading = false;
} else if (this.glFeatures.dastSavedScans && !runAfterCreate) { } else if (this.glFeatures.dastSavedScans && !runAfterCreate) {
redirectTo(response.dastProfile.editPath); redirectTo(response.dastProfile.editPath);
this.clearStorage = true;
} else { } else {
this.clearStorage = true;
redirectTo(response.pipelineUrl); redirectTo(response.pipelineUrl);
} }
}) })
...@@ -305,12 +320,31 @@ export default { ...@@ -305,12 +320,31 @@ export default {
this.errors = []; this.errors = [];
this.showAlert = false; this.showAlert = false;
}, },
updateFromStorage(val) {
const { selectedSiteProfileId, selectedScannerProfileId, name, description } = val;
this.form.fields.name.value = name ?? this.form.fields.name.value;
this.form.fields.description.value = description ?? this.form.fields.description.value;
// precedence is given to profile IDs passed from the query params
this.selectedSiteProfileId = this.selectedSiteProfileId ?? selectedSiteProfileId;
this.selectedScannerProfileId = this.selectedScannerProfileId ?? selectedScannerProfileId;
}, },
},
ON_DEMAND_SCANS_STORAGE_KEY,
}; };
</script> </script>
<template> <template>
<gl-form novalidate @submit.prevent="onSubmit()"> <gl-form novalidate @submit.prevent="onSubmit()">
<local-storage-sync
v-if="glFeatures.dastSavedScans && !isEdit"
as-json
:storage-key="$options.ON_DEMAND_SCANS_STORAGE_KEY"
:clear="clearStorage"
:value="formFieldValues"
@input="updateFromStorage"
/>
<header class="gl-mb-6"> <header class="gl-mb-6">
<div class="gl-mt-6 gl-display-flex"> <div class="gl-mt-6 gl-display-flex">
<h2 class="gl-flex-grow-1 gl-my-0">{{ title }}</h2> <h2 class="gl-flex-grow-1 gl-my-0">{{ title }}</h2>
......
...@@ -199,6 +199,7 @@ export default { ...@@ -199,6 +199,7 @@ export default {
}, },
hideDropdown() { hideDropdown() {
this.isDropdownShowing = this.isDropdownVariantStandalone; this.isDropdownShowing = this.isDropdownVariantStandalone;
this.$emit('hide');
}, },
toggleFormDropdown() { toggleFormDropdown() {
const { dropdown } = this.$refs.dropdown.$refs; const { dropdown } = this.$refs.dropdown.$refs;
......
...@@ -44,6 +44,7 @@ module EE ...@@ -44,6 +44,7 @@ module EE
mount_mutation ::Mutations::DastOnDemandScans::Create mount_mutation ::Mutations::DastOnDemandScans::Create
mount_mutation ::Mutations::Dast::Profiles::Create mount_mutation ::Mutations::Dast::Profiles::Create
mount_mutation ::Mutations::Dast::Profiles::Delete mount_mutation ::Mutations::Dast::Profiles::Delete
mount_mutation ::Mutations::Dast::Profiles::Run
mount_mutation ::Mutations::DastSiteProfiles::Create mount_mutation ::Mutations::DastSiteProfiles::Create
mount_mutation ::Mutations::DastSiteProfiles::Update mount_mutation ::Mutations::DastSiteProfiles::Update
mount_mutation ::Mutations::DastSiteProfiles::Delete mount_mutation ::Mutations::DastSiteProfiles::Delete
......
# frozen_string_literal: true
module Mutations
module Dast
module Profiles
class Run < BaseMutation
include FindsProject
graphql_name 'DastProfileRun'
ProfileID = ::Types::GlobalIDType[::Dast::Profile]
field :pipeline_url, GraphQL::STRING_TYPE,
null: true,
description: 'URL of the pipeline that was created.'
argument :full_path, GraphQL::ID_TYPE,
required: true,
description: 'Full path for the project the scanner profile belongs to.'
argument :id, ProfileID,
required: true,
description: 'ID of the profile to be used for the scan.'
authorize :create_on_demand_dast_scan
def resolve(full_path:, id:)
project = authorized_find!(full_path)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless allowed?(project)
# TODO: remove this line once the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ProfileID.coerce_isolated_input(id).model_id
dast_profile = find_dast_profile(project, id)
return { errors: ['Profile not found for given parameters'] } unless dast_profile
response = create_on_demand_dast_scan(project, dast_profile)
return { errors: response.errors } if response.error?
{ errors: [], pipeline_url: response.payload.fetch(:pipeline_url) }
end
private
def allowed?(project)
project.feature_available?(:security_on_demand_scans) &&
Feature.enabled?(:dast_saved_scans, project, default_enabled: :yaml)
end
def find_dast_profile(project, id)
::Dast::ProfilesFinder.new(project_id: project.id, id: id)
.execute
.first
end
def create_on_demand_dast_scan(project, dast_profile)
::DastOnDemandScans::CreateService.new(
container: project,
current_user: current_user,
params: {
dast_site_profile: dast_profile.dast_site_profile,
dast_scanner_profile: dast_profile.dast_scanner_profile
}
).execute
end
end
end
end
end
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
- milestone_lists_available = board.resource_parent.feature_available?(:board_milestone_lists) - milestone_lists_available = board.resource_parent.feature_available?(:board_milestone_lists)
- if assignee_lists_available || milestone_lists_available - if assignee_lists_available || milestone_lists_available
.dropdown.boards-add-list.gl-ml-3#js-add-list .dropdown.boards-add-list.gl-ml-3.gl-display-flex.gl-align-items-center#js-add-list
%button.btn.gl-button.btn-success.btn-inverted.d-flex.js-new-board-list{ type: "button", data: board_list_data } %button.btn.gl-button.btn-confirm.btn-confirm-secondary.gl-display-flex.js-new-board-list{ type: "button", data: board_list_data }
%span Add list %span Add list
= sprite_icon('chevron-down', css_class: 'gl-ml-2 btn-success-board-list-chevron') = sprite_icon('chevron-down', css_class: 'gl-ml-2 btn-success-board-list-chevron')
.dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.dropdown-menu-tabs.pt-0 .dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.dropdown-menu-tabs.pt-0
......
...@@ -27,7 +27,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -27,7 +27,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
wrapper = null; wrapper = null;
}); });
const fakeStore = ({ const createStore = ({
initialState = { initialState = {
activeId: mockIssueWithoutEpic.id, activeId: mockIssueWithoutEpic.id,
issues: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } }, issues: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } },
...@@ -59,7 +59,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -59,7 +59,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
BoardEditableItem, BoardEditableItem,
EpicsSelect: stubComponent(EpicsSelect, { EpicsSelect: stubComponent(EpicsSelect, {
methods: { methods: {
handleEditClick: epicsSelectHandleEditClick, toggleFormDropdown: epicsSelectHandleEditClick,
}, },
}), }),
}, },
...@@ -69,9 +69,43 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -69,9 +69,43 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
const findEpicSelect = () => wrapper.find({ ref: 'epicSelect' }); const findEpicSelect = () => wrapper.find({ ref: 'epicSelect' });
const findItemWrapper = () => wrapper.find({ ref: 'sidebarItem' }); const findItemWrapper = () => wrapper.find({ ref: 'sidebarItem' });
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findBoardEditableItem = () => wrapper.find(BoardEditableItem);
describe('when not editing', () => {
it('expands the milestone dropdown on clicking edit', async () => {
createStore();
createWrapper();
await findBoardEditableItem().vm.$emit('open');
expect(epicsSelectHandleEditClick).toHaveBeenCalled();
});
});
describe('when editing', () => {
beforeEach(() => {
createStore();
createWrapper();
findItemWrapper().vm.$emit('open');
jest.spyOn(wrapper.vm.$refs.sidebarItem, 'collapse');
});
it('collapses BoardEditableItem on clicking edit', async () => {
await findBoardEditableItem().vm.$emit('close');
expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1);
});
it('collapses BoardEditableItem on hiding dropdown', async () => {
await wrapper.find(EpicsSelect).vm.$emit('hide');
expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1);
});
});
it('renders "None" when no epic is assigned to the active issue', async () => { it('renders "None" when no epic is assigned to the active issue', async () => {
fakeStore(); createStore();
createWrapper(); createWrapper();
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
...@@ -83,7 +117,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -83,7 +117,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
it('fetches an epic for active issue', () => { it('fetches an epic for active issue', () => {
const fetchEpicForActiveIssue = jest.fn(() => Promise.resolve()); const fetchEpicForActiveIssue = jest.fn(() => Promise.resolve());
fakeStore({ createStore({
initialState: { initialState: {
activeId: mockIssueWithEpic.id, activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } }, issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
...@@ -101,7 +135,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -101,7 +135,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
}); });
it('flashes an error message when fetch fails', async () => { it('flashes an error message when fetch fails', async () => {
fakeStore({ createStore({
initialState: { initialState: {
activeId: mockIssueWithEpic.id, activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } }, issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
...@@ -126,7 +160,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -126,7 +160,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
}); });
it('renders epic title when issue has an assigned epic', async () => { it('renders epic title when issue has an assigned epic', async () => {
fakeStore({ createStore({
initialState: { initialState: {
activeId: mockIssueWithEpic.id, activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } }, issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
...@@ -143,18 +177,9 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -143,18 +177,9 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
}); });
}); });
it('expands the dropdown when editing', () => {
fakeStore();
createWrapper();
findItemWrapper().vm.$emit('open');
expect(epicsSelectHandleEditClick).toHaveBeenCalled();
});
describe('when epic is selected', () => { describe('when epic is selected', () => {
beforeEach(async () => { beforeEach(async () => {
fakeStore({ createStore({
initialState: { initialState: {
activeId: mockIssueWithoutEpic.id, activeId: mockIssueWithoutEpic.id,
issues: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } }, issues: { [mockIssueWithoutEpic.id]: { ...mockIssueWithoutEpic } },
...@@ -190,11 +215,25 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -190,11 +215,25 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
expect(findCollapsed().isVisible()).toBe(true); expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe(mockAssignedEpic.title); expect(findCollapsed().text()).toBe(mockAssignedEpic.title);
}); });
describe('when the selected epic did not change', () => {
it('does not commit change to the server', async () => {
createStore();
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueEpic').mockImplementation();
findEpicSelect().vm.$emit('epicSelect', null);
await wrapper.vm.$nextTick();
expect(wrapper.vm.setActiveIssueEpic).not.toHaveBeenCalled();
});
});
}); });
describe('when no epic is selected', () => { describe('when no epic is selected', () => {
beforeEach(async () => { beforeEach(async () => {
fakeStore({ createStore({
initialState: { initialState: {
activeId: mockIssueWithEpic.id, activeId: mockIssueWithEpic.id,
issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } }, issues: { [mockIssueWithEpic.id]: { ...mockIssueWithEpic } },
...@@ -226,7 +265,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -226,7 +265,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
}); });
it('flashes an error when update fails', async () => { it('flashes an error when update fails', async () => {
fakeStore({ createStore({
actionsMock: { actionsMock: {
setActiveIssueEpic: jest.fn().mockRejectedValue('mayday'), setActiveIssueEpic: jest.fn().mockRejectedValue('mayday'),
}, },
...@@ -234,7 +273,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => { ...@@ -234,7 +273,7 @@ describe('ee/boards/components/sidebar/board_sidebar_epic_select.vue', () => {
createWrapper(); createWrapper();
findEpicSelect().vm.$emit('epicSelect', null); findEpicSelect().vm.$emit('epicSelect', { id: 'foo' });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
......
...@@ -13,6 +13,8 @@ import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/gr ...@@ -13,6 +13,8 @@ import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/gr
import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql'; import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; import { redirectTo, setUrlParams } from '~/lib/utils/url_utility';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import * as responses from '../mocks/apollo_mocks'; import * as responses from '../mocks/apollo_mocks';
import { scannerProfiles, siteProfiles } from '../mocks/mock_data'; import { scannerProfiles, siteProfiles } from '../mocks/mock_data';
...@@ -43,6 +45,7 @@ const dastScan = { ...@@ -43,6 +45,7 @@ const dastScan = {
siteProfileId: validatedSiteProfile.id, siteProfileId: validatedSiteProfile.id,
}; };
useLocalStorageSpy();
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute, isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute,
queryToObject: jest.requireActual('~/lib/utils/url_utility').queryToObject, queryToObject: jest.requireActual('~/lib/utils/url_utility').queryToObject,
...@@ -50,6 +53,8 @@ jest.mock('~/lib/utils/url_utility', () => ({ ...@@ -50,6 +53,8 @@ jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(), redirectTo: jest.fn(),
})); }));
const LOCAL_STORAGE_KEY = 'on-demand-scans-new-form';
describe('OnDemandScansForm', () => { describe('OnDemandScansForm', () => {
let localVue; let localVue;
let subject; let subject;
...@@ -143,6 +148,7 @@ describe('OnDemandScansForm', () => { ...@@ -143,6 +148,7 @@ describe('OnDemandScansForm', () => {
}, },
stubs: { stubs: {
GlFormInput: GlFormInputStub, GlFormInput: GlFormInputStub,
LocalStorageSync,
}, },
}, },
{ ...options, localVue, apolloProvider }, { ...options, localVue, apolloProvider },
...@@ -164,6 +170,7 @@ describe('OnDemandScansForm', () => { ...@@ -164,6 +170,7 @@ describe('OnDemandScansForm', () => {
afterEach(() => { afterEach(() => {
subject.destroy(); subject.destroy();
subject = null; subject = null;
localStorage.clear();
}); });
it('renders properly', () => { it('renders properly', () => {
...@@ -216,6 +223,45 @@ describe('OnDemandScansForm', () => { ...@@ -216,6 +223,45 @@ describe('OnDemandScansForm', () => {
}); });
}); });
describe('local storage', () => {
it('get updated when form is modified', async () => {
mountShallowSubject();
await setValidFormData();
expect(localStorage.setItem.mock.calls).toEqual([
[
LOCAL_STORAGE_KEY,
JSON.stringify({
name: 'My daily scan',
selectedScannerProfileId: 'gid://gitlab/DastScannerProfile/1',
selectedSiteProfileId: 'gid://gitlab/DastSiteProfile/1',
}),
],
]);
});
it('reload the form data when available', async () => {
localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify({
name: dastScan.name,
description: dastScan.description,
selectedScannerProfileId: dastScan.scannerProfileId,
selectedSiteProfileId: dastScan.siteProfileId,
}),
);
mountShallowSubject();
await subject.vm.$nextTick();
expect(findNameInput().attributes('value')).toBe(dastScan.name);
expect(findDescriptionInput().attributes('value')).toBe(dastScan.description);
expect(findScannerProfilesSelector().attributes('value')).toBe(dastScan.scannerProfileId);
expect(findSiteProfilesSelector().attributes('value')).toBe(dastScan.siteProfileId);
});
});
describe('submit button', () => { describe('submit button', () => {
let submitButton; let submitButton;
...@@ -271,7 +317,6 @@ describe('OnDemandScansForm', () => { ...@@ -271,7 +317,6 @@ describe('OnDemandScansForm', () => {
variables: { variables: {
input: { input: {
name: 'My daily scan', name: 'My daily scan',
description: '',
dastScannerProfileId: passiveScannerProfile.id, dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id, dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath, fullPath: projectPath,
...@@ -288,6 +333,10 @@ describe('OnDemandScansForm', () => { ...@@ -288,6 +333,10 @@ describe('OnDemandScansForm', () => {
it('does not show an alert', async () => { it('does not show an alert', async () => {
expect(findAlert().exists()).toBe(false); expect(findAlert().exists()).toBe(false);
}); });
it('clears local storage', () => {
expect(localStorage.removeItem.mock.calls).toEqual([[LOCAL_STORAGE_KEY]]);
});
}); });
describe('when editing an existing scan', () => { describe('when editing an existing scan', () => {
...@@ -539,5 +588,25 @@ describe('OnDemandScansForm', () => { ...@@ -539,5 +588,25 @@ describe('OnDemandScansForm', () => {
expect(subject.find(SiteProfileSelector).attributes('value')).toBe(siteProfile.id); expect(subject.find(SiteProfileSelector).attributes('value')).toBe(siteProfile.id);
expect(subject.find(ScannerProfileSelector).attributes('value')).toBe(scannerProfile.id); expect(subject.find(ScannerProfileSelector).attributes('value')).toBe(scannerProfile.id);
}); });
it('when local storage data is available', async () => {
localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify({
selectedScannerProfileId: dastScan.scannerProfileId,
selectedSiteProfileId: dastScan.siteProfileId,
}),
);
global.jsdom.reconfigure({
url: setUrlParams({ site_profile_id: 1, scanner_profile_id: 1 }, URL_HOST),
});
mountShallowSubject();
await subject.vm.$nextTick();
expect(findScannerProfilesSelector().attributes('value')).toBe(scannerProfile.id);
expect(findSiteProfilesSelector().attributes('value')).toBe(siteProfile.id);
});
}); });
}); });
...@@ -137,6 +137,12 @@ describe('EpicsSelect', () => { ...@@ -137,6 +137,12 @@ describe('EpicsSelect', () => {
expect(wrapperStandalone.vm.isDropdownShowing).toBe(true); expect(wrapperStandalone.vm.isDropdownShowing).toBe(true);
}); });
it('should emit `hide` event', () => {
wrapperStandalone.vm.hideDropdown();
expect(wrapperStandalone.emitted().hide.length).toBe(1);
});
}); });
describe('handleItemSelect', () => { describe('handleItemSelect', () => {
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Dast::Profiles::Run do
let_it_be_with_refind(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let_it_be(:dast_profile) { create(:dast_profile, project: project) }
let(:full_path) { project.full_path }
let(:dast_profile_id) { dast_profile.to_global_id }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
specify { expect(described_class).to require_graphql_authorizations(:create_on_demand_dast_scan) }
describe '#resolve' do
subject do
mutation.resolve(
full_path: full_path,
id: dast_profile_id
)
end
context 'when the feature flag dast_saved_scans is disabled' do
it 'raises an exception' do
stub_licensed_features(security_on_demand_scans: true)
stub_feature_flags(dast_saved_scans: false)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when on demand scan licensed feature is not available' do
it 'raises an exception' do
stub_licensed_features(security_on_demand_scans: false)
stub_feature_flags(dast_saved_scans: true)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the feature is enabled' do
before do
stub_licensed_features(security_on_demand_scans: true)
stub_feature_flags(dast_saved_scans: true)
end
context 'when the project does not exist' do
let(:full_path) { SecureRandom.hex }
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user can run a dast scan' do
before do
project.add_developer(user)
end
it 'returns a pipeline_url containing the correct path' do
actual_url = subject[:pipeline_url]
pipeline = Ci::Pipeline.last
expected_url = Gitlab::Routing.url_helpers.project_pipeline_url(
project,
pipeline
)
expect(actual_url).to eq(expected_url)
end
context 'when the dast_profile does not exist' do
let(:dast_profile_id) { Gitlab::GlobalId.build(nil, model_name: 'Dast::Profile', id: 'does_not_exist') }
it 'communicates failure' do
expect(subject[:errors]).to include('Profile not found for given parameters')
end
end
context 'when scan_type=active' do
let(:dast_scanner_profile) { create(:dast_scanner_profile, project: project, scan_type: 'active') }
let(:dast_profile) { create(:dast_profile, project: project, dast_scanner_profile: dast_scanner_profile) }
context 'when target is not validated' do
it 'communicates failure' do
expect(subject[:errors]).to include('Cannot run active scan against unvalidated target')
end
end
context 'when target is validated' do
it 'has no errors' do
create(:dast_site_validation, state: :passed, dast_site_token: create(:dast_site_token, project: project, url: dast_profile.dast_site_profile.dast_site.url))
expect(subject[:errors]).to be_empty
end
end
end
end
end
end
end
...@@ -76,7 +76,8 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicatedCsFindingsWithoutVul ...@@ -76,7 +76,8 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicatedCsFindingsWithoutVul
end end
def finding_params(primary_identifier_id, project_id) def finding_params(primary_identifier_id, project_id)
attrs = attributes_for(:vulnerabilities_finding) # rubocop: disable RSpec/FactoriesInMigrationSpecs uuid = SecureRandom.uuid
{ {
severity: 0, severity: 0,
confidence: 5, confidence: 5,
...@@ -84,23 +85,112 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicatedCsFindingsWithoutVul ...@@ -84,23 +85,112 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicatedCsFindingsWithoutVul
project_id: project_id, project_id: project_id,
scanner_id: 6, scanner_id: 6,
primary_identifier_id: primary_identifier_id, primary_identifier_id: primary_identifier_id,
project_fingerprint: attrs[:project_fingerprint], project_fingerprint: SecureRandom.hex(20),
location_fingerprint: Digest::SHA1.hexdigest(SecureRandom.hex(10)), location_fingerprint: Digest::SHA1.hexdigest(SecureRandom.hex(10)),
uuid: SecureRandom.uuid, uuid: uuid,
name: attrs[:name], name: "Vulnerability Finding #{uuid}",
metadata_version: '1.3', metadata_version: '1.3',
raw_metadata: attrs[:raw_metadata] raw_metadata: raw_metadata
} }
end end
def create_identifier(number_of) def raw_metadata
(1..number_of).each do |identifier_id| {
identifiers.create!(id: identifier_id, "description" => "The cipher does not provide data integrity update 1",
project_id: 123, "message" => "The cipher does not provide data integrity",
fingerprint: 'd432c2ad2953e8bd587a3a43b3ce309b5b0154c' + identifier_id.to_s, "cve" => "818bf5dacb291e15d9e6dc3c5ac32178:CIPHER",
external_type: 'SECURITY_ID', "solution" => "GCM mode introduces an HMAC into the resulting encrypted data, providing integrity of the result.",
external_id: 'SECURITY_0', "location" => {
name: 'SECURITY_IDENTIFIER 0') "file" => "maven/src/main/java/com/gitlab/security_products/tests/App.java",
end "start_line" => 29,
"end_line" => 29,
"class" => "com.gitlab.security_products.tests.App",
"method" => "insecureCypher"
},
"links" => [
{
"name" => "Cipher does not check for integrity first?",
"url" => "https://crypto.stackexchange.com/questions/31428/pbewithmd5anddes-cipher-does-not-check-for-integrity-first"
}
],
"assets" => [
{
"type" => "postman",
"name" => "Test Postman Collection",
"url" => "http://localhost/test.collection"
}
],
"evidence" => {
"summary" => "Credit card detected",
"request" => {
"method" => "GET",
"url" => "http://goat:8080/WebGoat/logout",
"body" => nil,
"headers" => [
{
"name" => "Accept",
"value" => "*/*"
}
]
},
"response" => {
"reason_phrase" => "OK",
"status_code" => 200,
"body" => nil,
"headers" => [
{
"name" => "Content-Length",
"value" => "0"
}
]
},
"source" => {
"id" => "assert:Response Body Analysis",
"name" => "Response Body Analysis",
"url" => "htpp://hostname/documentation"
},
"supporting_messages" => [
{
"name" => "Origional",
"request" => {
"method" => "GET",
"url" => "http://goat:8080/WebGoat/logout",
"body" => "",
"headers" => [
{
"name" => "Accept",
"value" => "*/*"
}
]
}
},
{
"name" => "Recorded",
"request" => {
"method" => "GET",
"url" => "http://goat:8080/WebGoat/logout",
"body" => "",
"headers" => [
{
"name" => "Accept",
"value" => "*/*"
}
]
},
"response" => {
"reason_phrase" => "OK",
"status_code" => 200,
"body" => "",
"headers" => [
{
"name" => "Content-Length",
"value" => "0"
}
]
}
}
]
}
}
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Running a DAST Profile' do
include GraphqlHelpers
let!(:dast_profile) { create(:dast_profile, project: project) }
let(:mutation_name) { :dast_profile_run }
let(:mutation) do
graphql_mutation(
mutation_name,
full_path: project.full_path,
id: global_id_of(dast_profile)
)
end
it_behaves_like 'an on-demand scan mutation when user cannot run an on-demand scan'
it_behaves_like 'an on-demand scan mutation when user can run an on-demand scan' do
it 'returns a pipeline_url containing the correct path' do
post_graphql_mutation(mutation, current_user: current_user)
pipeline = Ci::Pipeline.last
expected_url = Gitlab::Routing.url_helpers.project_pipeline_url(
project,
pipeline
)
expect(mutation_response['pipelineUrl']).to eq(expected_url)
end
context 'when pipeline creation fails' do
before do
allow_next_instance_of(Ci::Pipeline) do |instance|
allow(instance).to receive(:created_successfully?).and_return(false)
allow(instance).to receive(:full_error_messages).and_return('error message')
end
end
it_behaves_like 'a mutation that returns errors in the response', errors: ['error message']
end
end
end
# See Usage Ping metrics dictionary docs https://docs.gitlab.com/ee/development/usage_ping/metrics_dictionary.html # See Usage Ping metrics dictionary docs https://docs.gitlab.com/ee/development/usage_ping/metrics_dictionary.html
key_path: <%= key_path %> key_path: <%= key_path %>
value_type: description:
product_section:
product_stage:
product_group:
product_category: product_category:
stage: value_type: <%= value_type %>
status: status: implemented
milestone: milestone:
introduced_by_url: introduced_by_url:
group:
time_frame: <%= time_frame %> time_frame: <%= time_frame %>
data_source: data_source:
distribution: <%= distribution %> distribution: <%= distribution %>
# tier: ['free', 'starter', 'premium', 'ultimate', 'bronze', 'silver', 'gold'] # tier: ['free', 'premium', 'ultimate']
tier: tier:
...@@ -4,18 +4,18 @@ require 'rails/generators' ...@@ -4,18 +4,18 @@ require 'rails/generators'
module Gitlab module Gitlab
class UsageMetricDefinitionGenerator < Rails::Generators::Base class UsageMetricDefinitionGenerator < Rails::Generators::Base
Directory = Struct.new(:name, :time_frame) do Directory = Struct.new(:name, :time_frame, :value_type) do
def match?(str) def match?(str)
(name == str || time_frame == str) && str != 'none' (name == str || time_frame == str) && str != 'none'
end end
end end
TIME_FRAME_DIRS = [ TIME_FRAME_DIRS = [
Directory.new('counts_7d', '7d'), Directory.new('counts_7d', '7d', 'number'),
Directory.new('counts_28d', '28d'), Directory.new('counts_28d', '28d', 'number'),
Directory.new('counts_all', 'all'), Directory.new('counts_all', 'all', 'number'),
Directory.new('settings', 'none'), Directory.new('settings', 'none', 'boolean'),
Directory.new('license', 'none') Directory.new('license', 'none', 'string')
].freeze ].freeze
VALID_INPUT_DIRS = (TIME_FRAME_DIRS.flat_map { |d| [d.name, d.time_frame] } - %w(none)).freeze VALID_INPUT_DIRS = (TIME_FRAME_DIRS.flat_map { |d| [d.name, d.time_frame] } - %w(none)).freeze
...@@ -40,6 +40,10 @@ module Gitlab ...@@ -40,6 +40,10 @@ module Gitlab
directory&.time_frame directory&.time_frame
end end
def value_type
directory&.value_type
end
def distribution def distribution
value = ['ce'] value = ['ce']
value << 'ee' if ee? value << 'ee' if ee?
......
...@@ -119,6 +119,16 @@ ...@@ -119,6 +119,16 @@
category: code_review category: code_review
aggregation: weekly aggregation: weekly
feature_flag: usage_data_i_code_review_user_assigned feature_flag: usage_data_i_code_review_user_assigned
- name: i_code_review_user_marked_as_draft
redis_slot: code_review
category: code_review
aggregation: weekly
feature_flag: usage_data_i_code_review_user_marked_as_draft
- name: i_code_review_user_unmarked_as_draft
redis_slot: code_review
category: code_review
aggregation: weekly
feature_flag: usage_data_i_code_review_user_unmarked_as_draft
- name: i_code_review_user_review_requested - name: i_code_review_user_review_requested
redis_slot: code_review redis_slot: code_review
category: code_review category: code_review
......
...@@ -22,6 +22,8 @@ module Gitlab ...@@ -22,6 +22,8 @@ module Gitlab
MR_REMOVE_MULTILINE_COMMENT_ACTION = 'i_code_review_user_remove_multiline_mr_comment' MR_REMOVE_MULTILINE_COMMENT_ACTION = 'i_code_review_user_remove_multiline_mr_comment'
MR_ADD_SUGGESTION_ACTION = 'i_code_review_user_add_suggestion' MR_ADD_SUGGESTION_ACTION = 'i_code_review_user_add_suggestion'
MR_APPLY_SUGGESTION_ACTION = 'i_code_review_user_apply_suggestion' MR_APPLY_SUGGESTION_ACTION = 'i_code_review_user_apply_suggestion'
MR_MARKED_AS_DRAFT_ACTION = 'i_code_review_user_marked_as_draft'
MR_UNMARKED_AS_DRAFT_ACTION = 'i_code_review_user_unmarked_as_draft'
MR_RESOLVE_THREAD_ACTION = 'i_code_review_user_resolve_thread' MR_RESOLVE_THREAD_ACTION = 'i_code_review_user_resolve_thread'
MR_UNRESOLVE_THREAD_ACTION = 'i_code_review_user_unresolve_thread' MR_UNRESOLVE_THREAD_ACTION = 'i_code_review_user_unresolve_thread'
MR_ASSIGNED_USERS_ACTION = 'i_code_review_user_assigned' MR_ASSIGNED_USERS_ACTION = 'i_code_review_user_assigned'
...@@ -101,6 +103,14 @@ module Gitlab ...@@ -101,6 +103,14 @@ module Gitlab
track_unique_action_by_user(MR_ADD_SUGGESTION_ACTION, user) track_unique_action_by_user(MR_ADD_SUGGESTION_ACTION, user)
end end
def track_marked_as_draft_action(user:)
track_unique_action_by_user(MR_MARKED_AS_DRAFT_ACTION, user)
end
def track_unmarked_as_draft_action(user:)
track_unique_action_by_user(MR_UNMARKED_AS_DRAFT_ACTION, user)
end
def track_apply_suggestion_action(user:) def track_apply_suggestion_action(user:)
track_unique_action_by_user(MR_APPLY_SUGGESTION_ACTION, user) track_unique_action_by_user(MR_APPLY_SUGGESTION_ACTION, user)
end end
......
...@@ -21,6 +21,14 @@ FactoryBot.define do ...@@ -21,6 +21,14 @@ FactoryBot.define do
merge_status { "can_be_merged" } merge_status { "can_be_merged" }
trait :draft_merge_request do
title { generate(:draft_title) }
end
trait :wip_merge_request do
title { generate(:wip_title) }
end
trait :jira_title do trait :jira_title do
title { generate(:jira_title) } title { generate(:jira_title) }
end end
......
...@@ -15,6 +15,8 @@ FactoryBot.define do ...@@ -15,6 +15,8 @@ FactoryBot.define do
sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") } sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") }
sequence(:oid) { |n| Digest::SHA2.hexdigest("oid-like-#{n}") } sequence(:oid) { |n| Digest::SHA2.hexdigest("oid-like-#{n}") }
sequence(:variable) { |n| "var#{n}" } sequence(:variable) { |n| "var#{n}" }
sequence(:draft_title) { |n| "Draft: #{n}" }
sequence(:wip_title) { |n| "WIP: #{n}" }
sequence(:jira_title) { |n| "[PROJ-#{n}]: fix bug" } sequence(:jira_title) { |n| "[PROJ-#{n}]: fix bug" }
sequence(:jira_branch) { |n| "feature/PROJ-#{n}" } sequence(:jira_branch) { |n| "feature/PROJ-#{n}" }
end end
...@@ -575,7 +575,7 @@ RSpec.describe 'Issue Boards', :js do ...@@ -575,7 +575,7 @@ RSpec.describe 'Issue Boards', :js do
end end
it 'shows the button' do it 'shows the button' do
expect(page).to have_link('Toggle focus mode') expect(page).to have_button('Toggle focus mode')
end end
end end
......
import { GlAvatarLink, GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui'; import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
...@@ -14,7 +14,7 @@ describe('AdminUserAvatar component', () => { ...@@ -14,7 +14,7 @@ describe('AdminUserAvatar component', () => {
const findNote = () => wrapper.find(GlIcon); const findNote = () => wrapper.find(GlIcon);
const findAvatar = () => wrapper.find(GlAvatarLabeled); const findAvatar = () => wrapper.find(GlAvatarLabeled);
const findAvatarLink = () => wrapper.find(GlAvatarLink); const findUserLink = () => wrapper.find('.js-user-link');
const findAllBadges = () => wrapper.findAll(GlBadge); const findAllBadges = () => wrapper.findAll(GlBadge);
const findTooltip = () => getBinding(findNote().element, 'gl-tooltip'); const findTooltip = () => getBinding(findNote().element, 'gl-tooltip');
...@@ -44,20 +44,25 @@ describe('AdminUserAvatar component', () => { ...@@ -44,20 +44,25 @@ describe('AdminUserAvatar component', () => {
initComponent(); initComponent();
}); });
it("links to the user's admin path", () => { it('adds a user link hover card', () => {
expect(findAvatarLink().attributes()).toMatchObject({ expect(findUserLink().attributes()).toMatchObject({
href: adminUserPath.replace('id', user.username),
'data-user-id': user.id.toString(), 'data-user-id': user.id.toString(),
'data-username': user.username, 'data-username': user.username,
}); });
}); });
it("renders the user's name", () => { it("renders the user's name with an admin path link", () => {
expect(findAvatar().props('label')).toBe(user.name); const avatar = findAvatar();
expect(avatar.props('label')).toBe(user.name);
expect(avatar.props('labelLink')).toBe(adminUserPath.replace('id', user.username));
}); });
it("renders the user's email", () => { it("renders the user's email with a mailto link", () => {
expect(findAvatar().props('subLabel')).toBe(user.email); const avatar = findAvatar();
expect(avatar.props('subLabel')).toBe(user.email);
expect(avatar.props('subLabelLink')).toBe(`mailto:${user.email}`);
}); });
it("renders the user's avatar image", () => { it("renders the user's avatar image", () => {
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon, GlDropdown } from '@gitlab/ui';
import { mockMilestone as TEST_MILESTONE } from 'jest/boards/mock_data'; import { mockMilestone as TEST_MILESTONE } from 'jest/boards/mock_data';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
...@@ -46,10 +46,42 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => ...@@ -46,10 +46,42 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () =>
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findLoader = () => wrapper.find(GlLoadingIcon); const findLoader = () => wrapper.find(GlLoadingIcon);
const findDropdown = () => wrapper.find(GlDropdown);
const findBoardEditableItem = () => wrapper.find(BoardEditableItem);
const findDropdownItem = () => wrapper.find('[data-testid="milestone-item"]'); const findDropdownItem = () => wrapper.find('[data-testid="milestone-item"]');
const findUnsetMilestoneItem = () => wrapper.find('[data-testid="no-milestone-item"]'); const findUnsetMilestoneItem = () => wrapper.find('[data-testid="no-milestone-item"]');
const findNoMilestonesFoundItem = () => wrapper.find('[data-testid="no-milestones-found"]'); const findNoMilestonesFoundItem = () => wrapper.find('[data-testid="no-milestones-found"]');
describe('when not editing', () => {
it('opens the milestone dropdown on clicking edit', async () => {
createWrapper();
wrapper.vm.$refs.dropdown.show = jest.fn();
await findBoardEditableItem().vm.$emit('open');
expect(wrapper.vm.$refs.dropdown.show).toHaveBeenCalledTimes(1);
});
});
describe('when editing', () => {
beforeEach(() => {
createWrapper();
jest.spyOn(wrapper.vm.$refs.sidebarItem, 'collapse');
});
it('collapses BoardEditableItem on clicking edit', async () => {
await findBoardEditableItem().vm.$emit('close');
expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1);
});
it('collapses BoardEditableItem on hiding dropdown', async () => {
await findDropdown().vm.$emit('hide');
expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1);
});
});
it('renders "None" when no milestone is selected', () => { it('renders "None" when no milestone is selected', () => {
createWrapper(); createWrapper();
......
...@@ -3,12 +3,13 @@ ...@@ -3,12 +3,13 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'validate puma' do RSpec.describe 'validate puma' do
include RakeHelpers
subject do subject do
load Rails.root.join('config/initializers/validate_puma.rb') load Rails.root.join('config/initializers/validate_puma.rb')
end end
before do before do
stub_env('PUMA_SKIP_CLUSTER_VALIDATION', skip_validation)
stub_const('Puma', double) stub_const('Puma', double)
allow(Gitlab::Runtime).to receive(:puma?).and_return(true) allow(Gitlab::Runtime).to receive(:puma?).and_return(true)
allow(Puma).to receive_message_chain(:cli_config, :options).and_return(workers: workers) allow(Puma).to receive_message_chain(:cli_config, :options).and_return(workers: workers)
...@@ -22,70 +23,44 @@ RSpec.describe 'validate puma' do ...@@ -22,70 +23,44 @@ RSpec.describe 'validate puma' do
context 'when worker count is 0' do context 'when worker count is 0' do
let(:workers) { 0 } let(:workers) { 0 }
context 'and PUMA_SKIP_CLUSTER_VALIDATION is true' do
let(:skip_validation) { true }
specify { expect { subject }.to raise_error(String) } specify { expect { subject }.to raise_error(String) }
end end
context 'and PUMA_SKIP_CLUSTER_VALIDATION is false' do
let(:skip_validation) { false }
specify { expect { subject }.to raise_error(String) }
end
end
context 'when worker count is > 0' do context 'when worker count is > 0' do
let(:workers) { 2 } let(:workers) { 2 }
context 'and PUMA_SKIP_CLUSTER_VALIDATION is true' do
let(:skip_validation) { true }
specify { expect { subject }.not_to raise_error }
end
context 'and PUMA_SKIP_CLUSTER_VALIDATION is false' do
let(:skip_validation) { false }
specify { expect { subject }.not_to raise_error } specify { expect { subject }.not_to raise_error }
end end
end end
end
context 'for other environments' do context 'for other environments' do
before do before do
allow(Gitlab).to receive(:com?).and_return(false) allow(Gitlab).to receive(:com?).and_return(false)
allow(main_object).to receive(:warn)
end end
context 'when worker count is 0' do context 'when worker count is 0' do
let(:workers) { 0 } let(:workers) { 0 }
context 'and PUMA_SKIP_CLUSTER_VALIDATION is true' do
let(:skip_validation) { true }
specify { expect { subject }.not_to raise_error } specify { expect { subject }.not_to raise_error }
end
context 'and PUMA_SKIP_CLUSTER_VALIDATION is false' do it 'warns about running Puma in a Single mode' do
let(:skip_validation) { false } expect(main_object).to receive(:warn) do |warning|
expect(warning).to include('https://gitlab.com/groups/gitlab-org/-/epics/5303')
end
specify { expect { subject }.to raise_error(String) } subject
end end
end end
context 'when worker count is > 0' do context 'when worker count is > 0' do
let(:workers) { 2 } let(:workers) { 2 }
context 'and PUMA_SKIP_CLUSTER_VALIDATION is true' do
let(:skip_validation) { true }
specify { expect { subject }.not_to raise_error } specify { expect { subject }.not_to raise_error }
end
context 'and PUMA_SKIP_CLUSTER_VALIDATION is false' do it 'does not issue a warning' do
let(:skip_validation) { false } expect(main_object).not_to receive(:warn)
specify { expect { subject }.not_to raise_error }
end end
end end
end end
......
...@@ -221,6 +221,22 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl ...@@ -221,6 +221,22 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
end end
end end
describe '.track_marked_as_draft_action' do
subject { described_class.track_marked_as_draft_action(user: user) }
it_behaves_like 'a tracked merge request unique event' do
let(:action) { described_class::MR_MARKED_AS_DRAFT_ACTION }
end
end
describe '.track_unmarked_as_draft_action' do
subject { described_class.track_unmarked_as_draft_action(user: user) }
it_behaves_like 'a tracked merge request unique event' do
let(:action) { described_class::MR_UNMARKED_AS_DRAFT_ACTION }
end
end
describe '.track_users_review_requested' do describe '.track_users_review_requested' do
subject { described_class.track_users_review_requested(users: [user]) } subject { described_class.track_users_review_requested(users: [user]) }
......
...@@ -629,25 +629,4 @@ RSpec.describe DesignManagement::Design do ...@@ -629,25 +629,4 @@ RSpec.describe DesignManagement::Design do
end end
end end
end end
describe '#immediately_before' do
let_it_be(:design) { create(:design, issue: issue, relative_position: 100) }
let_it_be(:next_design) { create(:design, issue: issue, relative_position: 200) }
it 'is true when there is no element positioned between this item and the next' do
expect(design.immediately_before?(next_design)).to be true
end
it 'is false when there is an element positioned between this item and the next' do
create(:design, issue: issue, relative_position: 150)
expect(design.immediately_before?(next_design)).to be false
end
it 'is false when the next design is to the left of this design' do
further_left = create(:design, issue: issue, relative_position: 50)
expect(design.immediately_before?(further_left)).to be false
end
end
end end
...@@ -1322,7 +1322,16 @@ RSpec.describe API::MergeRequests do ...@@ -1322,7 +1322,16 @@ RSpec.describe API::MergeRequests do
end end
context 'Work in Progress' do context 'Work in Progress' do
let!(:merge_request_wip) { create(:merge_request, author: user, assignees: [user], source_project: project, target_project: project, title: "WIP: Test", created_at: base_time + 1.second) } let!(:merge_request_wip) do
create(:merge_request,
author: user,
assignees: [user],
source_project: project,
target_project: project,
title: "WIP: Test",
created_at: base_time + 1.second
)
end
it "returns merge request" do it "returns merge request" do
get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.iid}", user) get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.iid}", user)
......
...@@ -76,18 +76,6 @@ RSpec.describe DesignManagement::MoveDesignsService do ...@@ -76,18 +76,6 @@ RSpec.describe DesignManagement::MoveDesignsService do
end end
end end
context 'the designs are not adjacent' do
let(:current_design) { designs.first }
let(:previous_design) { designs.second }
let(:next_design) { designs.third }
it 'raises not_adjacent' do
create(:design, issue: issue, relative_position: next_design.relative_position - 1)
expect(subject).to be_error.and(have_attributes(message: :not_adjacent))
end
end
context 'moving a design with neighbours' do context 'moving a design with neighbours' do
let(:current_design) { designs.first } let(:current_design) { designs.first }
let(:previous_design) { designs.second } let(:previous_design) { designs.second }
......
...@@ -89,6 +89,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -89,6 +89,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
context 'usage counters' do context 'usage counters' do
let(:merge_request2) { create(:merge_request) } let(:merge_request2) { create(:merge_request) }
let(:draft_merge_request) { create(:merge_request, :draft_merge_request)}
it 'update as expected' do it 'update as expected' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
...@@ -98,6 +99,24 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -98,6 +99,24 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
MergeRequests::UpdateService.new(project, user, opts).execute(merge_request2) MergeRequests::UpdateService.new(project, user, opts).execute(merge_request2)
end end
it 'tracks Draft/WIP marking' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_marked_as_draft_action).once.with(user: user)
opts[:title] = "WIP: #{opts[:title]}"
MergeRequests::UpdateService.new(project, user, opts).execute(merge_request2)
end
it 'tracks Draft/WIP un-marking' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_unmarked_as_draft_action).once.with(user: user)
opts[:title] = "Non-draft/wip title string"
MergeRequests::UpdateService.new(project, user, opts).execute(draft_merge_request)
end
end end
context 'updating milestone' do context 'updating milestone' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment