Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
43b91399ae
commit
93c27b216a
|
@ -1 +1 @@
|
|||
13.9.1
|
||||
13.10.1
|
||||
|
|
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -309,4 +309,8 @@ export default {
|
|||
[mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
|
||||
state.selectedBoardItems = [];
|
||||
},
|
||||
|
||||
[mutationTypes.SET_ERROR]: (state, error) => {
|
||||
state.error = error;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
/* eslint-disable @gitlab/require-i18n-strings */
|
||||
export const IssueType = 'Issue';
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add blocked issues detail popover for boards cards
|
||||
merge_request: 55821
|
||||
author:
|
||||
type: added
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 |
|
@ -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
|
||||
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
@ -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>"
|
||||
`;
|
|
@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue