Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-03-16 06:09:57 +00:00
parent 43b91399ae
commit 93c27b216a
38 changed files with 1243 additions and 141 deletions

View File

@ -1 +1 @@
13.9.1
13.10.1

View File

@ -0,0 +1,192 @@
<script>
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
import { IssueType } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { truncate } from '~/lib/utils/text_utility';
import { __, n__, s__, sprintf } from '~/locale';
export default {
i18n: {
issuableType: {
[issuableTypes.issue]: __('issue'),
},
},
graphQLIdType: {
[issuableTypes.issue]: IssueType,
},
referenceFormatter: {
[issuableTypes.issue]: (r) => r.split('/')[1],
},
defaultDisplayLimit: 3,
textTruncateWidth: 80,
components: {
GlIcon,
GlPopover,
GlLink,
GlLoadingIcon,
},
blockingIssuablesQueries,
props: {
item: {
type: Object,
required: true,
},
uniqueId: {
type: String,
required: true,
},
issuableType: {
type: String,
required: true,
validator(value) {
return [issuableTypes.issue].includes(value);
},
},
},
apollo: {
blockingIssuables: {
skip() {
return this.skip;
},
query() {
return blockingIssuablesQueries[this.issuableType].query;
},
variables() {
return {
id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id),
};
},
update(data) {
this.skip = true;
return data?.issuable?.blockingIssuables?.nodes || [];
},
error(error) {
const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), {
issuableType: this.issuableTypeText,
});
this.$emit('blocking-issuables-error', { error, message });
},
},
},
data() {
return {
skip: true,
blockingIssuables: [],
};
},
computed: {
displayedIssuables() {
const { defaultDisplayLimit, referenceFormatter } = this.$options;
return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => {
return {
...i,
title: truncate(i.title, this.$options.textTruncateWidth),
reference: referenceFormatter[this.issuableType](i.reference),
};
});
},
loading() {
return this.$apollo.queries.blockingIssuables.loading;
},
issuableTypeText() {
return this.$options.i18n.issuableType[this.issuableType];
},
blockedLabel() {
return sprintf(
n__(
'Boards|Blocked by %{blockedByCount} %{issuableType}',
'Boards|Blocked by %{blockedByCount} %{issuableType}s',
this.item.blockedByCount,
),
{
blockedByCount: this.item.blockedByCount,
issuableType: this.issuableTypeText,
},
);
},
glIconId() {
return `blocked-icon-${this.uniqueId}`;
},
hasMoreIssuables() {
return this.item.blockedByCount > this.$options.defaultDisplayLimit;
},
displayedIssuablesCount() {
return this.hasMoreIssuables
? this.item.blockedByCount - this.$options.defaultDisplayLimit
: this.item.blockedByCount;
},
moreIssuablesText() {
return sprintf(
n__(
'Boards|+ %{displayedIssuablesCount} more %{issuableType}',
'Boards|+ %{displayedIssuablesCount} more %{issuableType}s',
this.displayedIssuablesCount,
),
{
displayedIssuablesCount: this.displayedIssuablesCount,
issuableType: this.issuableTypeText,
},
);
},
viewAllIssuablesText() {
return sprintf(s__('Boards|View all blocking %{issuableType}s'), {
issuableType: this.issuableTypeText,
});
},
loadingMessage() {
return sprintf(s__('Boards|Retrieving blocking %{issuableType}s'), {
issuableType: this.issuableTypeText,
});
},
},
methods: {
handleMouseEnter() {
this.skip = false;
},
},
};
</script>
<template>
<div class="gl-display-inline">
<gl-icon
:id="glIconId"
ref="icon"
name="issue-block"
class="issue-blocked-icon gl-mr-2 gl-cursor-pointer"
data-testid="issue-blocked-icon"
@mouseenter="handleMouseEnter"
/>
<gl-popover :target="glIconId" placement="top" triggers="hover">
<template #title
><span data-testid="popover-title">{{ blockedLabel }}</span></template
>
<template v-if="loading">
<gl-loading-icon />
<p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p>
</template>
<template v-else>
<ul class="gl-list-style-none gl-p-0">
<li v-for="issuable in displayedIssuables" :key="issuable.id">
<gl-link :href="issuable.webUrl" class="gl-text-blue-500! gl-font-sm">{{
issuable.reference
}}</gl-link>
<p class="gl-mb-3 gl-display-block!" data-testid="issuable-title">
{{ issuable.title }}
</p>
</li>
</ul>
<div v-if="hasMoreIssuables" class="gl-mt-4">
<p class="gl-mb-3" data-testid="hidden-blocking-count">{{ moreIssuablesText }}</p>
<gl-link
data-testid="view-all-issues"
:href="`${item.webUrl}#related-issues`"
class="gl-text-blue-500! gl-font-sm"
>{{ viewAllIssuablesText }}</gl-link
>
</div>
</template>
</gl-popover>
</div>
</template>

View File

@ -10,6 +10,7 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import { ListType } from '../constants';
import eventHub from '../eventhub';
import BoardBlockedIcon from './board_blocked_icon.vue';
import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue';
@ -22,6 +23,7 @@ export default {
IssueDueDate,
IssueTimeEstimate,
IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
BoardBlockedIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -52,7 +54,7 @@ export default {
};
},
computed: {
...mapState(['isShowingLabels']),
...mapState(['isShowingLabels', 'issuableType']),
...mapGetters(['isEpicBoard']),
cappedAssignees() {
// e.g. maxRender is 4,
@ -114,7 +116,7 @@ export default {
},
},
methods: {
...mapActions(['performSearch']),
...mapActions(['performSearch', 'setError']),
isIndexLessThanlimit(index) {
return index < this.limitBeforeCounter;
},
@ -164,14 +166,12 @@ export default {
<div>
<div class="gl-display-flex" dir="auto">
<h4 class="board-card-title gl-mb-0 gl-mt-0">
<gl-icon
<board-blocked-icon
v-if="item.blocked"
v-gl-tooltip
name="issue-block"
:title="blockedLabel"
class="issue-blocked-icon gl-mr-2"
:aria-label="blockedLabel"
data-testid="issue-blocked-icon"
:item="item"
:unique-id="`${item.id}${list.id}`"
:issuable-type="issuableType"
@blocking-issuables-error="setError"
/>
<gl-icon
v-if="item.confidential"
@ -181,13 +181,9 @@ export default {
class="confidential-icon gl-mr-2"
:aria-label="__('Confidential')"
/>
<a
:href="item.path || item.webUrl || ''"
:title="item.title"
class="js-no-trigger"
@mousemove.stop
>{{ item.title }}</a
>
<a :href="item.path || item.webUrl || ''" :title="item.title" @mousemove.stop>{{
item.title
}}</a>
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">

View File

@ -17,7 +17,7 @@ export default {
gon.features?.graphqlBoardLists || gon.features?.epicBoards
? BoardColumn
: BoardColumnDeprecated,
BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'),
BoardContentSidebar: () => import('~/boards/components/board_content_sidebar.vue'),
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
},
@ -69,7 +69,7 @@ export default {
},
},
methods: {
...mapActions(['moveList']),
...mapActions(['moveList', 'unsetError']),
afterFormEnters() {
const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
@ -100,7 +100,7 @@ export default {
<template>
<div>
<gl-alert v-if="error" variant="danger" :dismissible="false">
<gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="unsetError">
{{ error }}
</gl-alert>
<component
@ -134,6 +134,9 @@ export default {
:disabled="disabled"
/>
<board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" />
<board-content-sidebar
v-if="isSwimlanesOn || glFeatures.graphqlBoardLists"
class="issue-boards-sidebar"
/>
</div>
</template>

View File

@ -0,0 +1,101 @@
<script>
import { GlDrawer } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import BoardSidebarEpicSelect from 'ee_component/boards/components/sidebar/board_sidebar_epic_select.vue';
import BoardSidebarWeightInput from 'ee_component/boards/components/sidebar/board_sidebar_weight_input.vue';
import SidebarIterationWidget from 'ee_component/sidebar/components/sidebar_iteration_widget.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import { ISSUABLE } from '~/boards/constants';
import { contentTop } from '~/lib/utils/common_utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
headerHeight: `${contentTop()}px`,
components: {
GlDrawer,
BoardSidebarIssueTitle,
SidebarAssigneesWidget,
BoardSidebarTimeTracker,
BoardSidebarLabelsSelect,
BoardSidebarDueDate,
BoardSidebarSubscription,
BoardSidebarMilestoneSelect,
BoardSidebarEpicSelect,
SidebarIterationWidget,
BoardSidebarWeightInput,
},
mixins: [glFeatureFlagsMixin()],
computed: {
...mapGetters([
'isSidebarOpen',
'activeIssue',
'groupPathForActiveIssue',
'projectPathForActiveIssue',
]),
...mapState(['sidebarType', 'issuableType']),
isIssuableSidebar() {
return this.sidebarType === ISSUABLE;
},
showSidebar() {
return this.isIssuableSidebar && this.isSidebarOpen;
},
fullPath() {
return this.activeIssue?.referencePath?.split('#')[0] || '';
},
},
methods: {
...mapActions(['toggleBoardItem', 'setAssignees']),
updateAssignees(data) {
const assignees = data.issueSetAssignees?.issue?.assignees?.nodes || [];
this.setAssignees(assignees);
},
handleClose() {
this.toggleBoardItem({ boardItem: this.activeIssue, sidebarType: this.sidebarType });
},
},
};
</script>
<template>
<gl-drawer
v-if="showSidebar"
data-testid="sidebar-drawer"
:open="isSidebarOpen"
:header-height="$options.headerHeight"
@close="handleClose"
>
<template #header>{{ __('Issue details') }}</template>
<template #default>
<board-sidebar-issue-title />
<sidebar-assignees-widget
:iid="activeIssue.iid"
:full-path="fullPath"
:initial-assignees="activeIssue.assignees"
class="assignee"
@assignees-updated="updateAssignees"
/>
<board-sidebar-epic-select class="epic" />
<div>
<board-sidebar-milestone-select />
<sidebar-iteration-widget
:iid="activeIssue.iid"
:workspace-path="projectPathForActiveIssue"
:iterations-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
class="gl-mt-5"
/>
</div>
<board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
<board-sidebar-due-date />
<board-sidebar-labels-select class="labels" />
<board-sidebar-weight-input v-if="glFeatures.issueWeights" class="weight" />
<board-sidebar-subscription class="subscriptions" />
</template>
</gl-drawer>
</template>

View File

@ -98,14 +98,14 @@ export default {
<gl-button
v-if="canUpdate"
variant="link"
class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle"
class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle edit-link"
data-testid="edit-button"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
</header>
<div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
<div v-show="!edit" class="gl-text-gray-500 value" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content">

View File

@ -0,0 +1,25 @@
<script>
import { mapGetters } from 'vuex';
import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
export default {
components: {
IssuableTimeTracker,
},
inject: ['timeTrackingLimitToHours'],
computed: {
...mapGetters(['activeIssue']),
},
};
</script>
<template>
<issuable-time-tracker
:time-estimate="activeIssue.timeEstimate"
:time-spent="activeIssue.totalTimeSpent"
:human-time-estimate="activeIssue.humanTimeEstimate"
:human-time-spent="activeIssue.humanTotalTimeSpent"
:limit-to-hours="timeTrackingLimitToHours"
:show-collapsed="false"
/>
</template>

View File

@ -1,4 +1,5 @@
import { __ } from '~/locale';
import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql';
export const issuableTypes = {
issue: 'issue',
@ -45,3 +46,9 @@ export default {
BoardType,
ListType,
};
export const blockingIssuablesQueries = {
[issuableTypes.issue]: {
query: boardBlockingIssuesQuery,
},
};

View File

@ -0,0 +1,16 @@
query BoardBlockingIssues($id: IssueID!) {
issuable: issue(id: $id) {
__typename
id
blockingIssuables: blockedByIssues {
__typename
nodes {
id
iid
title
reference(full: true)
webUrl
}
}
}
}

View File

@ -107,6 +107,7 @@ export default () => {
milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable),
assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable),
iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable),
issuableType: issuableTypes.issue,
},
store,
apolloProvider,
@ -340,7 +341,7 @@ export default () => {
},
computed: {
disabled() {
if (!this.store) {
if (!this.store || !this.store.lists) {
return true;
}
return !this.store.lists.filter((list) => !list.preset).length;

View File

@ -1,3 +1,4 @@
import * as Sentry from '@sentry/browser';
import { pick } from 'lodash';
import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
@ -608,6 +609,18 @@ export default {
}
},
setError: ({ commit }, { message, error, captureError = false }) => {
commit(types.SET_ERROR, message);
if (captureError) {
Sentry.captureException(error);
}
},
unsetError: ({ commit }) => {
commit(types.SET_ERROR, undefined);
},
fetchBacklog: () => {
notImplemented();
},

View File

@ -49,3 +49,4 @@ export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE';
export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
export const SET_ERROR = 'SET_ERROR';

View File

@ -309,4 +309,8 @@ export default {
[mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
state.selectedBoardItems = [];
},
[mutationTypes.SET_ERROR]: (state, error) => {
state.error = error;
},
};

View File

@ -0,0 +1,2 @@
/* eslint-disable @gitlab/require-i18n-strings */
export const IssueType = 'Issue';

View File

@ -29,7 +29,7 @@ module DropdownsHelper
output << dropdown_filter(options[:placeholder])
end
output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do
output << content_tag(:div, data: { qa_selector: "dropdown_list_content" }, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do
capture(&block) if block && !options.key?(:footer_content)
end
@ -102,7 +102,7 @@ module DropdownsHelper
def dropdown_filter(placeholder, search_id: nil)
content_tag :div, class: "dropdown-input" do
filter_output = search_field_tag search_id, nil, class: "dropdown-input-field qa-dropdown-input-field", placeholder: placeholder, autocomplete: 'off'
filter_output = search_field_tag search_id, nil, data: { qa_selector: "dropdown_input_field" }, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off'
filter_output << sprite_icon('search', css_class: 'dropdown-input-search')
filter_output << sprite_icon('close', size: 16, css_class: 'dropdown-input-clear js-dropdown-input-clear')

View File

@ -857,7 +857,7 @@ class NotificationService
end
def warn_skipping_notifications(user, object)
Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class, object_id: object.id)
Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class.to_s, object_id: object.id)
end
end

View File

@ -45,7 +45,7 @@
= render_if_exists 'groups/custom_project_templates_setting'
= render_if_exists 'groups/templates_setting', expanded: expanded
%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) }
%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
= _('Advanced')

View File

@ -28,7 +28,7 @@
%h4.warning-title= s_('GroupSettings|Transfer group')
= form_for @group, url: transfer_group_path(@group), method: :put, html: { class: 'js-group-transfer-form' } do |f|
.form-group
= dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', data: { data: parent_group_options(@group) } })
= dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', data: { data: parent_group_options(@group), qa_selector: 'select_group_dropdown' } })
= hidden_field_tag 'new_parent_group_id'
%ul
@ -38,7 +38,7 @@
%li= s_('GroupSettings|You can only transfer the group to a group you manage.')
%li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
%li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
= f.submit s_('GroupSettings|Transfer group'), class: 'btn gl-button btn-warning'
= f.submit s_('GroupSettings|Transfer group'), class: 'btn gl-button btn-warning', data: { qa_selector: "transfer_group_button" }
= render 'groups/settings/remove', group: @group
= render_if_exists 'groups/settings/restore', group: @group

View File

@ -0,0 +1,5 @@
---
title: Add blocked issues detail popover for boards cards
merge_request: 55821
author:
type: added

View File

@ -131,6 +131,18 @@ Example response:
"version": "1.5.0"
}
},
"details": {
"custom_field": {
"name": "URLs",
"type": "list",
"items": [
{
"type": "url",
"href": "http://site.com/page/1"
}
]
}
},
"solution": "Upgrade to fixed version.\r\n",
"blob_path": "/tests/yarn-remediation-test/blob/cc6c4a0778460455ae5d16ca7025ca9ca1ca75ac/yarn.lock"
}

View File

@ -7,125 +7,135 @@ type: reference, howto
# Dynamic Application Security Testing (DAST) **(ULTIMATE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/4348) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.4.
If you deploy your web application into a new environment, your application may
become exposed to new types of attacks. For example, misconfigurations of your
application server or incorrect assumptions about security controls may not be
visible from the source code.
Your application may be exposed to a new category of attacks once deployed into a new environment. For
example, application server misconfigurations or incorrect assumptions about security controls may
not be visible from source code alone. Dynamic Application Security Testing (DAST) checks an
application for these types of vulnerabilities in a deployed environment. GitLab DAST uses the
popular open source tool [OWASP Zed Attack Proxy](https://www.zaproxy.org/) to analyze your running
web application.
Dynamic Application Security Testing (DAST) examines applications for
vulnerabilities like these in deployed environments. DAST uses the open source
tool [OWASP Zed Attack Proxy](https://www.zaproxy.org/) for analysis.
NOTE:
The whitepaper ["A Seismic Shift in Application Security"](https://about.gitlab.com/resources/whitepaper-seismic-shift-application-security/)
explains how 4 of the top 6 attacks were application based. Download it to learn how to protect your
organization.
To learn how four of the top six attacks were application-based and how
to protect your organization, download our
["A Seismic Shift in Application Security"](https://about.gitlab.com/resources/whitepaper-seismic-shift-application-security/)
whitepaper.
In GitLab, DAST is commonly initiated by a merge request and runs as a job in the CI/CD pipeline.
You can also run a DAST scan on demand, outside the CI/CD pipeline. Your running web application is
analyzed for known vulnerabilities. GitLab checks the DAST report, compares the vulnerabilities
found between the source and target branches, and shows any relevant findings on the merge request.
You can use DAST to examine your web applications:
Note that this comparison logic uses only the latest pipeline executed for the target branch's base
commit. Running the pipeline on any other commit has no effect on the merge request.
- When initiated by a merge request, running as CI/CD pipeline job.
- On demand, outside the CI/CD pipeline.
![DAST widget, showing the vulnerability statistics and a list of vulnerabilities](img/dast_v13_4.png)
After DAST creates its report, GitLab evaluates it for discovered
vulnerabilities between the source and target branches. Relevant
findings are noted in the merge request.
The comparison logic uses only the latest pipeline executed for the target
branch's base commit. Running the pipeline on other commits has no effect on
the merge request.
## Prerequisite
To use DAST, ensure you're using GitLab Runner with the
[`docker` executor](https://docs.gitlab.com/runner/executors/docker.html).
## Enable DAST
### Prerequisites
- GitLab Runner with the [`docker` executor](https://docs.gitlab.com/runner/executors/docker.html).
To enable DAST, either:
- Enable [Auto DAST](../../../topics/autodevops/stages.md#auto-dast), provided by
[Auto DevOps](../../../topics/autodevops/index.md).
- [Include the DAST template](#dast-cicd-template) in your existing `.gitlab-ci.yml` file.
- Enable [Auto DAST](../../../topics/autodevops/stages.md#auto-dast) (provided
by [Auto DevOps](../../../topics/autodevops/index.md)).
- Manually [include the DAST template](#include-the-dast-template) in your existing
`.gitlab-ci.yml` file.
### DAST CI/CD template
### Include the DAST template
The DAST job is defined in a CI/CD template file you reference in your CI/CD configuration file. The
template is included with GitLab. Updates to the template are provided with GitLab upgrades. You
benefit from any improvements and additions.
If you want to manually add DAST to your application, the DAST job is defined
in a CI/CD template file. Updates to the template are provided with GitLab
upgrades, allowing you to benefit from any improvements and additions.
The following templates are available:
To include the DAST template:
- [`DAST.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml):
Stable version of the DAST CI/CD template.
- [`DAST.latest.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml):
Latest version of the DAST template. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/254325)
in GitLab 13.8). Please note that the latest version may include breaking changes. Check the
[DAST troubleshooting guide](#troubleshooting) if you experience problems.
1. Select the CI/CD template you want to use:
Use the stable template unless you need a feature provided only in the latest template.
- [`DAST.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml):
Stable version of the DAST CI/CD template.
- [`DAST.latest.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml):
Latest version of the DAST template. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/254325)
in GitLab 13.8).
See the CI/CD [documentation](../../../development/cicd/templates.md#latest-version)
on template versioning for more information.
WARNING:
The latest version of the template may include breaking changes. Use the
stable template unless you need a feature provided only in the latest template.
#### Include the DAST template
For more information about template versioning, see the
[CI/CD documentation](../../../development/cicd/templates.md#latest-version).
The method of including the DAST template depends on the GitLab version:
1. Add the template to GitLab, based on your version of GitLab:
- In GitLab 11.9 and later, [include](../../../ci/yaml/README.md#includetemplate) the
`DAST.gitlab-ci.yml` template.
- In GitLab 11.9 and later, [include](../../../ci/yaml/README.md#includetemplate)
the template by adding the following to your `.gitlab-ci.yml` file:
Add the following to your `.gitlab-ci.yml` file:
```yaml
include:
- template: <template_file.yml>
```yaml
include:
- template: DAST.gitlab-ci.yml
variables:
DAST_WEBSITE: https://example.com
```
variables:
DAST_WEBSITE: https://example.com
```
- In GitLab 11.8 and earlier, add the contents of the template to your
`.gitlab_ci.yml` file.
- In GitLab 11.8 and earlier, copy the template's content into your `.gitlab_ci.yml` file.
1. Define the URL to be scanned by DAST by using one of these methods:
#### Template options
- Set the `DAST_WEBSITE` [CI/CD variable](../../../ci/yaml/README.md#variables).
If set, this value takes precedence.
Running a DAST scan requires a URL. There are two ways to define the URL to be scanned by DAST:
- Add the URL in an `environment_url.txt` file at the root of your project. This is
useful for testing in dynamic environments. To run DAST against an application
dynamically created during a GitLab CI/CD pipeline, a job that runs prior to
the DAST scan must persist the application's domain in an `environment_url.txt`
file. DAST automatically parses the `environment_url.txt` file to find its
scan target.
1. Set the `DAST_WEBSITE` [CI/CD variable](../../../ci/yaml/README.md#variables).
For example, in a job that runs prior to DAST, you could include code that
looks similar to:
1. Add it in an `environment_url.txt` file at the root of your project.
This is useful for testing in dynamic environments. To run DAST against an application
dynamically created during a GitLab CI/CD pipeline, a job that runs prior to the DAST scan must
persist the application's domain in an `environment_url.txt` file. DAST automatically parses the
`environment_url.txt` file to find its scan target.
```yaml
script:
- echo http://${CI_PROJECT_ID}-${CI_ENVIRONMENT_SLUG}.domain.com > environment_url.txt
artifacts:
paths: [environment_url.txt]
when: always
```
For example, in a job that runs prior to DAST, you could include code that looks similar to:
```yaml
script:
- echo http://${CI_PROJECT_ID}-${CI_ENVIRONMENT_SLUG}.domain.com > environment_url.txt
artifacts:
paths: [environment_url.txt]
when: always
```
You can see an example of this in our [Auto DevOps CI YAML](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml) file.
If both values are set, the `DAST_WEBSITE` value takes precedence.
You can see an example of this in our
[Auto DevOps CI YAML](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml)
file.
The included template creates a `dast` job in your CI/CD pipeline and scans
your project's running application for possible vulnerabilities.
The results are saved as a
[DAST report artifact](../../../ci/pipelines/job_artifacts.md#artifactsreportsdast)
that you can later download and analyze. Due to implementation limitations we
that you can later download and analyze. Due to implementation limitations, we
always take the latest DAST artifact available. Behind the scenes, the
[GitLab DAST Docker image](https://gitlab.com/gitlab-org/security-products/dast)
is used to run the tests on the specified URL and scan it for possible vulnerabilities.
is used to run the tests on the specified URL and scan it for possible
vulnerabilities.
By default, the DAST template uses the latest major version of the DAST Docker
image. Using the `DAST_VERSION` variable, you can choose how DAST updates:
- Automatically update DAST with new features and fixes by pinning to a major version (such as `1`).
- Automatically update DAST with new features and fixes by pinning to a major
version (such as `1`).
- Only update fixes by pinning to a minor version (such as `1.6`).
- Prevent all updates by pinning to a specific version (such as `1.6.4`).
Find the latest DAST versions on the [Releases](https://gitlab.com/gitlab-org/security-products/dast/-/releases) page.
Find the latest DAST versions on the [Releases](https://gitlab.com/gitlab-org/security-products/dast/-/releases)
page.
## Deployment options
@ -747,7 +757,7 @@ successfully run. For more information, see [Offline environments](../offline_de
To use DAST in an offline environment, you need:
- GitLab Runner with the [`docker` or `kubernetes` executor](#prerequisites).
- GitLab Runner with the [`docker` or `kubernetes` executor](#prerequisite).
- Docker Container Registry with a locally available copy of the DAST
[container image](https://gitlab.com/gitlab-org/security-products/dast), found in the
[DAST container registry](https://gitlab.com/gitlab-org/security-products/dast/container_registry).

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -280,6 +280,7 @@ group-level objects are available.
#### GraphQL-based sidebar for group issue boards **(PREMIUM)**
<!-- When the feature flag is removed, integrate this section into the above ("Group issue boards"). -->
<!-- This anchor is linked from #blocked-issues as well. -->
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/285074) in GitLab 13.9.
> - It's [deployed behind a feature flag](../feature_flags.md), disabled by default.
@ -407,12 +408,18 @@ To set a WIP limit for a list:
## Blocked issues
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/34723) in GitLab 12.8.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/34723) in GitLab 12.8.
> - [View blocking issues when hovering over blocked icon](https://gitlab.com/gitlab-org/gitlab/-/issues/210452) in GitLab 13.10.
If an issue is blocked by another issue, an icon appears next to its title to indicate its blocked
status.
![Blocked issues](img/issue_boards_blocked_icon_v13_6.png)
When you hover over the blocked icon (**{issue-block}**), a detailed information popover is displayed.
To enable this in group issue boards, enable the [GraphQL-based sidebar](#graphql-based-sidebar-for-group-issue-boards).
The feature is enabled by default when you use group issue boards with epic swimlanes.
![Blocked issues](img/issue_boards_blocked_icon_v13_10.png)
## Actions you can take on an issue board

View File

@ -4880,6 +4880,11 @@ msgstr ""
msgid "Boards and Board Lists"
msgstr ""
msgid "Boards|+ %{displayedIssuablesCount} more %{issuableType}"
msgid_plural "Boards|+ %{displayedIssuablesCount} more %{issuableType}s"
msgstr[0] ""
msgstr[1] ""
msgid "Boards|An error occurred while creating the issue. Please try again."
msgstr ""
@ -4922,6 +4927,11 @@ msgstr ""
msgid "Boards|An error occurred while updating the list. Please try again."
msgstr ""
msgid "Boards|Blocked by %{blockedByCount} %{issuableType}"
msgid_plural "Boards|Blocked by %{blockedByCount} %{issuableType}s"
msgstr[0] ""
msgstr[1] ""
msgid "Boards|Board"
msgstr ""
@ -4934,6 +4944,15 @@ msgstr ""
msgid "Boards|Expand"
msgstr ""
msgid "Boards|Failed to fetch blocking %{issuableType}s"
msgstr ""
msgid "Boards|Retrieving blocking %{issuableType}s"
msgstr ""
msgid "Boards|View all blocking %{issuableType}s"
msgstr ""
msgid "Boards|View scope"
msgstr ""

View File

@ -9,6 +9,7 @@ module QA
view 'app/views/groups/edit.html.haml' do
element :permission_lfs_2fa_content
element :advanced_settings_content
end
view 'app/views/groups/settings/_permissions.html.haml' do
@ -40,6 +41,16 @@ module QA
element :project_creation_level_dropdown
end
view 'app/views/groups/settings/_advanced.html.haml' do
element :select_group_dropdown
element :transfer_group_button
end
view 'app/helpers/dropdowns_helper.rb' do
element :dropdown_input_field
element :dropdown_list_content
end
def set_group_name(name)
find_element(:group_name_field).send_keys([:command, 'a'], :backspace)
find_element(:group_name_field).set name
@ -106,6 +117,19 @@ module QA
click_element(:save_permissions_changes_button)
end
def transfer_group(target_group)
expand_content :advanced_settings_content
click_element :select_group_dropdown
fill_element(:dropdown_input_field, target_group)
within_element(:dropdown_list_content) do
click_on target_group
end
click_element :transfer_group_button
end
end
end
end

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
module QA
RSpec.describe 'Manage' do
describe 'Subgroup transfer' do
let(:source_group) do
Resource::Group.fabricate_via_api! do |group|
group.path = "source-group-for-transfer_#{SecureRandom.hex(8)}"
end
end
let!(:target_group) do
Resource::Group.fabricate_via_api! do |group|
group.path = "target-group-for-transfer_#{SecureRandom.hex(8)}"
end
end
let(:sub_group_for_transfer) do
Resource::Group.fabricate_via_api! do |group|
group.path = "subgroup-for-transfer_#{SecureRandom.hex(8)}"
group.sandbox = source_group
end
end
before do
Flow::Login.sign_in
sub_group_for_transfer.visit!
end
it 'transfers a subgroup to another group',
testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1724' do
Page::Group::Menu.perform(&:click_group_general_settings_item)
Page::Group::Settings::General.perform do |general|
general.transfer_group(target_group.path)
end
expect(page).to have_text("Group '#{sub_group_for_transfer.path}' was successfully transferred.")
expect(page.driver.current_url).to include("#{target_group.path}/#{sub_group_for_transfer.path}")
end
after do
source_group&.remove_via_api!
target_group&.remove_via_api!
sub_group_for_transfer&.remove_via_api!
end
end
end
end

View File

@ -1,11 +1,14 @@
import { GlLabel } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { range } from 'lodash';
import Vuex from 'vuex';
import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue';
import BoardCardInner from '~/boards/components/board_card_inner.vue';
import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import defaultStore from '~/boards/stores';
import { updateHistory } from '~/lib/utils/url_utility';
import { mockLabelList } from './mock_data';
import { mockLabelList, mockIssue } from './mock_data';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/boards/eventhub');
@ -29,8 +32,28 @@ describe('Board card component', () => {
let wrapper;
let issue;
let list;
let store;
const findBoardBlockedIcon = () => wrapper.find(BoardBlockedIcon);
const createStore = () => {
store = new Vuex.Store({
...defaultStore,
state: {
...defaultStore.state,
issuableType: issuableTypes.issue,
},
getters: {
isGroupBoard: () => true,
isEpicBoard: () => false,
isProjectBoard: () => false,
},
});
};
const createWrapper = (props = {}) => {
createStore();
const createWrapper = (props = {}, store = defaultStore) => {
wrapper = mount(BoardCardInner, {
store,
propsData: {
@ -41,6 +64,13 @@ describe('Board card component', () => {
stubs: {
GlLabel: true,
},
mocks: {
$apollo: {
queries: {
blockingIssuables: { loading: false },
},
},
},
provide: {
rootPath: '/',
scopedLabelsAvailable: false,
@ -51,14 +81,9 @@ describe('Board card component', () => {
beforeEach(() => {
list = mockLabelList;
issue = {
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
...mockIssue,
labels: [list.label],
assignees: [],
referencePath: '#1',
webUrl: '/test/1',
weight: 1,
};
@ -68,6 +93,7 @@ describe('Board card component', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
store = null;
jest.clearAllMocks();
});
@ -87,18 +113,38 @@ describe('Board card component', () => {
expect(wrapper.find('.confidential-icon').exists()).toBe(false);
});
it('does not render blocked icon', () => {
expect(wrapper.find('.issue-blocked-icon').exists()).toBe(false);
});
it('renders issue ID with #', () => {
expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`);
expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.iid}`);
});
it('does not render assignee', () => {
expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false);
});
describe('blocked', () => {
it('renders blocked icon if issue is blocked', async () => {
createWrapper({
item: {
...issue,
blocked: true,
},
});
expect(findBoardBlockedIcon().exists()).toBe(true);
});
it('does not show blocked icon if issue is not blocked', () => {
createWrapper({
item: {
...issue,
blocked: false,
},
});
expect(findBoardBlockedIcon().exists()).toBe(false);
});
});
describe('confidential issue', () => {
beforeEach(() => {
wrapper.setProps({
@ -303,21 +349,6 @@ describe('Board card component', () => {
});
});
describe('blocked', () => {
beforeEach(() => {
wrapper.setProps({
item: {
...wrapper.props('item'),
blocked: true,
},
});
});
it('renders blocked icon if issue is blocked', () => {
expect(wrapper.find('.issue-blocked-icon').exists()).toBe(true);
});
});
describe('filterByLabel method', () => {
beforeEach(() => {
delete window.location;

View File

@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BoardBlockedIcon on mouseenter on blocked icon with more than three blocking issues matches the snapshot 1`] = `
"<div class=\\"gl-display-inline\\"><svg data-testid=\\"issue-blocked-icon\\" aria-hidden=\\"true\\" class=\\"issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-icon s16\\" id=\\"blocked-icon-uniqueId\\">
<use href=\\"#issue-block\\"></use>
</svg>
<div class=\\"gl-popover\\">
<ul class=\\"gl-list-style-none gl-p-0\\">
<li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/6\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#6</a>
<p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\">
blocking issue title 1
</p>
</li>
<li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/5\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#5</a>
<p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\">
blocking issue title 2 + blocking issue title 2 + blocking issue title 2 + bloc…
</p>
</li>
<li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/4\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#4</a>
<p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\">
blocking issue title 3
</p>
</li>
</ul>
<div class=\\"gl-mt-4\\">
<p data-testid=\\"hidden-blocking-count\\" class=\\"gl-mb-3\\">+ 1 more issue</p> <a data-testid=\\"view-all-issues\\" href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0#related-issues\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">View all blocking issues</a>
</div><span data-testid=\\"popover-title\\">Blocked by 4 issues</span>
</div>
</div>"
`;

View File

@ -0,0 +1,226 @@
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue';
import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
import { truncate } from '~/lib/utils/text_utility';
import {
mockIssue,
mockBlockingIssue1,
mockBlockingIssue2,
mockBlockingIssuablesResponse1,
mockBlockingIssuablesResponse2,
mockBlockingIssuablesResponse3,
mockBlockedIssue1,
mockBlockedIssue2,
} from '../mock_data';
describe('BoardBlockedIcon', () => {
let wrapper;
let mockApollo;
const findGlIcon = () => wrapper.find(GlIcon);
const findGlPopover = () => wrapper.find(GlPopover);
const findGlLink = () => wrapper.find(GlLink);
const findPopoverTitle = () => wrapper.findByTestId('popover-title');
const findIssuableTitle = () => wrapper.findByTestId('issuable-title');
const findHiddenBlockingCount = () => wrapper.findByTestId('hidden-blocking-count');
const findViewAllIssuableLink = () => wrapper.findByTestId('view-all-issues');
const waitForApollo = async () => {
jest.runOnlyPendingTimers();
await waitForPromises();
};
const mouseenter = async () => {
findGlIcon().vm.$emit('mouseenter');
await wrapper.vm.$nextTick();
await waitForApollo();
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const createWrapperWithApollo = ({
item = mockBlockedIssue1,
blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1),
} = {}) => {
mockApollo = createMockApollo([
[blockingIssuablesQueries[issuableTypes.issue].query, blockingIssuablesSpy],
]);
Vue.use(VueApollo);
wrapper = extendedWrapper(
mount(BoardBlockedIcon, {
apolloProvider: mockApollo,
propsData: {
item: {
...mockIssue,
...item,
},
uniqueId: 'uniqueId',
issuableType: issuableTypes.issue,
},
attachTo: document.body,
}),
);
};
const createWrapper = ({ item = {}, queries = {}, data = {}, loading = false } = {}) => {
wrapper = extendedWrapper(
shallowMount(BoardBlockedIcon, {
propsData: {
item: {
...mockIssue,
...item,
},
uniqueId: 'uniqueid',
issuableType: issuableTypes.issue,
},
data() {
return {
...data,
};
},
mocks: {
$apollo: {
queries: {
blockingIssuables: { loading },
...queries,
},
},
},
stubs: {
GlPopover,
},
attachTo: document.body,
}),
);
};
it('should render blocked icon', () => {
createWrapper();
expect(findGlIcon().exists()).toBe(true);
});
it('should display a loading spinner while loading', () => {
createWrapper({ loading: true });
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('should not query for blocking issuables by default', async () => {
createWrapperWithApollo();
expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title);
});
describe('on mouseenter on blocked icon', () => {
it('should query for blocking issuables and render the result', async () => {
createWrapperWithApollo();
expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title);
await mouseenter();
expect(findGlPopover().exists()).toBe(true);
expect(findIssuableTitle().text()).toContain(mockBlockingIssue1.title);
expect(wrapper.vm.skip).toBe(true);
});
it('should emit "blocking-issuables-error" event on query error', async () => {
const mockError = new Error('mayday');
createWrapperWithApollo({ blockingIssuablesSpy: jest.fn().mockRejectedValue(mockError) });
await mouseenter();
const [
[
{
message,
error: { networkError },
},
],
] = wrapper.emitted('blocking-issuables-error');
expect(message).toBe('Failed to fetch blocking issues');
expect(networkError).toBe(mockError);
});
describe('with a single blocking issue', () => {
beforeEach(async () => {
createWrapperWithApollo();
await mouseenter();
});
it('should render a title of the issuable', async () => {
expect(findIssuableTitle().text()).toBe(mockBlockingIssue1.title);
});
it('should render issuable reference and link to the issuable', async () => {
const formattedRef = mockBlockingIssue1.reference.split('/')[1];
expect(findGlLink().text()).toBe(formattedRef);
expect(findGlLink().attributes('href')).toBe(mockBlockingIssue1.webUrl);
});
it('should render popover title with correct blocking issuable count', async () => {
expect(findPopoverTitle().text()).toBe('Blocked by 1 issue');
});
});
describe('when issue has a long title', () => {
it('should render a truncated title', async () => {
createWrapperWithApollo({
blockingIssuablesSpy: jest.fn().mockResolvedValue(mockBlockingIssuablesResponse2),
});
await mouseenter();
const truncatedTitle = truncate(
mockBlockingIssue2.title,
wrapper.vm.$options.textTruncateWidth,
);
expect(findIssuableTitle().text()).toBe(truncatedTitle);
});
});
describe('with more than three blocking issues', () => {
beforeEach(async () => {
createWrapperWithApollo({
item: mockBlockedIssue2,
blockingIssuablesSpy: jest.fn().mockResolvedValue(mockBlockingIssuablesResponse3),
});
await mouseenter();
});
it('matches the snapshot', () => {
expect(wrapper.html()).toMatchSnapshot();
});
it('should render popover title with correct blocking issuable count', async () => {
expect(findPopoverTitle().text()).toBe('Blocked by 4 issues');
});
it('should render the number of hidden blocking issuables', () => {
expect(findHiddenBlockingCount().text()).toBe('+ 1 more issue');
});
it('should link to the blocked issue page at the related issue anchor', async () => {
expect(findViewAllIssuableLink().text()).toBe('View all blocking issues');
expect(findViewAllIssuableLink().attributes('href')).toBe(
`${mockBlockedIssue2.webUrl}#related-issues`,
);
});
});
});
});

View File

@ -0,0 +1,125 @@
import { GlDrawer } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { stubComponent } from 'helpers/stub_component';
import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import { ISSUABLE } from '~/boards/constants';
import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data';
describe('BoardContentSidebar', () => {
let wrapper;
let store;
const createStore = ({ mockGetters = {}, mockActions = {} } = {}) => {
store = new Vuex.Store({
state: {
sidebarType: ISSUABLE,
issues: { [mockIssue.id]: mockIssue },
activeId: mockIssue.id,
issuableType: 'issue',
},
getters: {
activeIssue: () => mockIssue,
groupPathForActiveIssue: () => mockIssueGroupPath,
projectPathForActiveIssue: () => mockIssueProjectPath,
isSidebarOpen: () => true,
...mockGetters,
},
actions: mockActions,
});
};
const createComponent = () => {
wrapper = shallowMount(BoardContentSidebar, {
provide: {
canUpdate: true,
rootPath: '/',
groupId: '#',
},
store,
stubs: {
GlDrawer: stubComponent(GlDrawer, {
template: '<div><slot name="header"></slot><slot></slot></div>',
}),
},
mocks: {
$apollo: {
queries: {
participants: {
loading: false,
},
},
},
},
});
};
beforeEach(() => {
createStore();
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('confirms we render GlDrawer', () => {
expect(wrapper.find(GlDrawer).exists()).toBe(true);
});
it('does not render GlDrawer when isSidebarOpen is false', () => {
createStore({ mockGetters: { isSidebarOpen: () => false } });
createComponent();
expect(wrapper.find(GlDrawer).exists()).toBe(false);
});
it('applies an open attribute', () => {
expect(wrapper.find(GlDrawer).props('open')).toBe(true);
});
it('renders BoardSidebarLabelsSelect', () => {
expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true);
});
it('renders BoardSidebarIssueTitle', () => {
expect(wrapper.find(BoardSidebarIssueTitle).exists()).toBe(true);
});
it('renders BoardSidebarDueDate', () => {
expect(wrapper.find(BoardSidebarDueDate).exists()).toBe(true);
});
it('renders BoardSidebarSubscription', () => {
expect(wrapper.find(BoardSidebarSubscription).exists()).toBe(true);
});
it('renders BoardSidebarMilestoneSelect', () => {
expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true);
});
describe('when we emit close', () => {
let toggleBoardItem;
beforeEach(() => {
toggleBoardItem = jest.fn();
createStore({ mockActions: { toggleBoardItem } });
createComponent();
});
it('calls toggleBoardItem with correct parameters', async () => {
wrapper.find(GlDrawer).vm.$emit('close');
expect(toggleBoardItem).toHaveBeenCalledTimes(1);
expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
boardItem: mockIssue,
sidebarType: ISSUABLE,
});
});
});
});

View File

@ -0,0 +1,58 @@
/*
To avoid duplicating tests in time_tracker.spec,
this spec only contains a simple test to check rendering.
A detailed feature spec is used to test time tracking feature
in swimlanes sidebar.
*/
import { shallowMount } from '@vue/test-utils';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import { createStore } from '~/boards/stores';
import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
describe('BoardSidebarTimeTracker', () => {
let wrapper;
let store;
const createComponent = (options) => {
wrapper = shallowMount(BoardSidebarTimeTracker, {
store,
...options,
});
};
beforeEach(() => {
store = createStore();
store.state.boardItems = {
1: {
timeEstimate: 3600,
totalTimeSpent: 1800,
humanTimeEstimate: '1h',
humanTotalTimeSpent: '30min',
},
};
store.state.activeId = '1';
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it.each([[true], [false]])(
'renders IssuableTimeTracker with correct spent and estimated time (timeTrackingLimitToHours=%s)',
(timeTrackingLimitToHours) => {
createComponent({ provide: { timeTrackingLimitToHours } });
expect(wrapper.find(IssuableTimeTracker).props()).toEqual({
timeEstimate: 3600,
timeSpent: 1800,
humanTimeEstimate: '1h',
humanTimeSpent: '30min',
limitToHours: timeTrackingLimitToHours,
showCollapsed: false,
});
},
);
});

View File

@ -125,7 +125,7 @@ export const labels = [
export const rawIssue = {
title: 'Issue 1',
id: 'gid://gitlab/Issue/436',
iid: 27,
iid: '27',
dueDate: null,
timeEstimate: 0,
weight: null,
@ -152,7 +152,7 @@ export const rawIssue = {
export const mockIssue = {
id: 'gid://gitlab/Issue/436',
iid: 27,
iid: '27',
title: 'Issue 1',
dueDate: null,
timeEstimate: 0,
@ -398,3 +398,93 @@ export const mockActiveGroupProjects = [
{ ...mockGroupProject1, archived: false },
{ ...mockGroupProject2, archived: false },
];
export const mockIssueGroupPath = 'gitlab-org';
export const mockIssueProjectPath = `${mockIssueGroupPath}/gitlab-test`;
export const mockBlockingIssue1 = {
id: 'gid://gitlab/Issue/525',
iid: '6',
title: 'blocking issue title 1',
reference: 'gitlab-org/my-project-1#6',
webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/6',
__typename: 'Issue',
};
export const mockBlockingIssue2 = {
id: 'gid://gitlab/Issue/524',
iid: '5',
title:
'blocking issue title 2 + blocking issue title 2 + blocking issue title 2 + blocking issue title 2',
reference: 'gitlab-org/my-project-1#5',
webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/5',
__typename: 'Issue',
};
export const mockBlockingIssue3 = {
id: 'gid://gitlab/Issue/523',
iid: '4',
title: 'blocking issue title 3',
reference: 'gitlab-org/my-project-1#4',
webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/4',
__typename: 'Issue',
};
export const mockBlockingIssue4 = {
id: 'gid://gitlab/Issue/522',
iid: '3',
title: 'blocking issue title 4',
reference: 'gitlab-org/my-project-1#3',
webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/3',
__typename: 'Issue',
};
export const mockBlockingIssuablesResponse1 = {
data: {
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/527',
blockingIssuables: {
__typename: 'IssueConnection',
nodes: [mockBlockingIssue1],
},
},
},
};
export const mockBlockingIssuablesResponse2 = {
data: {
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/527',
blockingIssuables: {
__typename: 'IssueConnection',
nodes: [mockBlockingIssue2],
},
},
},
};
export const mockBlockingIssuablesResponse3 = {
data: {
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/527',
blockingIssuables: {
__typename: 'IssueConnection',
nodes: [mockBlockingIssue1, mockBlockingIssue2, mockBlockingIssue3, mockBlockingIssue4],
},
},
},
};
export const mockBlockedIssue1 = {
id: '527',
blockedByCount: 1,
};
export const mockBlockedIssue2 = {
id: '527',
blockedByCount: 4,
webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0',
};

View File

@ -1,3 +1,4 @@
import * as Sentry from '@sentry/browser';
import testAction from 'helpers/vuex_action_helper';
import {
fullBoardId,
@ -1378,6 +1379,51 @@ describe('toggleBoardItem', () => {
});
});
describe('setError', () => {
it('should commit mutation SET_ERROR', () => {
testAction({
action: actions.setError,
payload: { message: 'mayday' },
expectedMutations: [
{
payload: 'mayday',
type: types.SET_ERROR,
},
],
});
});
it('should capture error using Sentry when captureError is true', () => {
jest.spyOn(Sentry, 'captureException');
const mockError = new Error();
actions.setError(
{ commit: () => {} },
{
message: 'mayday',
error: mockError,
captureError: true,
},
);
expect(Sentry.captureException).toHaveBeenNthCalledWith(1, mockError);
});
});
describe('unsetError', () => {
it('should commit mutation SET_ERROR with undefined as payload', () => {
testAction({
action: actions.unsetError,
expectedMutations: [
{
payload: undefined,
type: types.SET_ERROR,
},
],
});
});
});
describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog);
});

View File

@ -666,4 +666,14 @@ describe('Board Store Mutations', () => {
expect(state.selectedBoardItems).toEqual([]);
});
});
describe('SET_ERROR', () => {
it('Should set error state', () => {
state.error = undefined;
mutations[types.SET_ERROR](state, 'mayday');
expect(state.error).toBe('mayday');
});
});
});

View File

@ -105,7 +105,7 @@ RSpec.describe NotificationService, :mailer do
recipient_1 = NotificationRecipient.new(user_1, :custom, custom_action: :new_release)
allow(NotificationRecipients::BuildService).to receive(:build_new_release_recipients).and_return([recipient_1])
expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: current_user.id, klass: object.class, object_id: object.id)
expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: current_user.id, klass: object.class.to_s, object_id: object.id)
action

View File

@ -49,7 +49,7 @@ RSpec.describe NewIssueWorker do
expect(Notify).not_to receive(:new_issue_email)
.with(mentioned.id, issue.id, NotificationReason::MENTIONED)
expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: issue.class, object_id: issue.id)
expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: issue.class.to_s, object_id: issue.id)
worker.perform(issue.id, user.id)
end

View File

@ -53,7 +53,7 @@ RSpec.describe NewMergeRequestWorker do
expect(Notify).not_to receive(:new_merge_request_email)
.with(mentioned.id, merge_request.id, NotificationReason::MENTIONED)
expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: merge_request.class, object_id: merge_request.id)
expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: merge_request.class.to_s, object_id: merge_request.id)
worker.perform(merge_request.id, user.id)
end