Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
1
Merge Requests
1
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
nexedi
gitlab-ce
Commits
f79ed8ad
Commit
f79ed8ad
authored
Mar 11, 2021
by
Eulyeon Ko
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add blocked popover feature for boards
Also - Add vuex action for setting error state
parent
1632d456
Changes
23
Show whitespace changes
Inline
Side-by-side
Showing
23 changed files
with
777 additions
and
86 deletions
+777
-86
app/assets/javascripts/boards/components/board_blocked_icon.vue
...sets/javascripts/boards/components/board_blocked_icon.vue
+192
-0
app/assets/javascripts/boards/components/board_card_inner.vue
...assets/javascripts/boards/components/board_card_inner.vue
+20
-16
app/assets/javascripts/boards/components/board_content.vue
app/assets/javascripts/boards/components/board_content.vue
+2
-2
app/assets/javascripts/boards/constants.js
app/assets/javascripts/boards/constants.js
+7
-0
app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
...cripts/boards/graphql/board_blocking_issues.query.graphql
+16
-0
app/assets/javascripts/boards/index.js
app/assets/javascripts/boards/index.js
+1
-0
app/assets/javascripts/boards/stores/actions.js
app/assets/javascripts/boards/stores/actions.js
+13
-0
app/assets/javascripts/boards/stores/mutation_types.js
app/assets/javascripts/boards/stores/mutation_types.js
+1
-0
app/assets/javascripts/boards/stores/mutations.js
app/assets/javascripts/boards/stores/mutations.js
+4
-0
app/assets/javascripts/boards/stores/state.js
app/assets/javascripts/boards/stores/state.js
+1
-0
app/assets/javascripts/graphql_shared/constants.js
app/assets/javascripts/graphql_shared/constants.js
+2
-0
changelogs/unreleased/implement-blocking-issue-popover-for-boards.yml
...nreleased/implement-blocking-issue-popover-for-boards.yml
+5
-0
doc/user/project/img/issue_boards_blocked_icon_v13_10.png
doc/user/project/img/issue_boards_blocked_icon_v13_10.png
+0
-0
doc/user/project/img/issue_boards_blocked_icon_v13_6.png
doc/user/project/img/issue_boards_blocked_icon_v13_6.png
+0
-0
doc/user/project/issue_board.md
doc/user/project/issue_board.md
+9
-2
ee/spec/frontend/boards/board_card_inner_spec.js
ee/spec/frontend/boards/board_card_inner_spec.js
+27
-38
locale/gitlab.pot
locale/gitlab.pot
+19
-0
spec/frontend/boards/board_card_inner_spec.js
spec/frontend/boards/board_card_inner_spec.js
+59
-28
spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap
.../components/__snapshots__/board_blocked_icon_spec.js.snap
+30
-0
spec/frontend/boards/components/board_blocked_icon_spec.js
spec/frontend/boards/components/board_blocked_icon_spec.js
+226
-0
spec/frontend/boards/mock_data.js
spec/frontend/boards/mock_data.js
+87
-0
spec/frontend/boards/stores/actions_spec.js
spec/frontend/boards/stores/actions_spec.js
+46
-0
spec/frontend/boards/stores/mutations_spec.js
spec/frontend/boards/stores/mutations_spec.js
+10
-0
No files found.
app/assets/javascripts/boards/components/board_blocked_icon.vue
0 → 100644
View file @
f79ed8ad
<
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>
app/assets/javascripts/boards/components/board_card_inner.vue
View file @
f79ed8ad
...
...
@@ -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,13 @@ export default {
},
},
methods
:
{
...
mapActions
([
'
performSearch
'
]),
...
mapActions
([
'
performSearch
'
,
'
setError
'
,
'
setBlockingIssuables
'
,
'
unsetBlockingIssuables
'
,
'
toggleBlockedPopover
'
,
]),
isIndexLessThanlimit
(
index
)
{
return
index
<
this
.
limitBeforeCounter
;
},
...
...
@@ -164,14 +172,14 @@ 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
"
@blocking-issuables="setBlockingIssuables
"
@hidden="unsetBlockingIssuables
"
/>
<gl-icon
v-if=
"item.confidential"
...
...
@@ -181,13 +189,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"
>
...
...
app/assets/javascripts/boards/components/board_content.vue
View file @
f79ed8ad
...
...
@@ -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
...
...
app/assets/javascripts/boards/constants.js
View file @
f79ed8ad
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
,
},
};
app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
0 → 100644
View file @
f79ed8ad
query
BoardBlockingIssues
(
$id
:
IssueID
!)
{
issuable
:
issue
(
id
:
$id
)
{
__typename
id
blockingIssuables
:
blockedByIssues
{
__typename
nodes
{
id
iid
title
reference
(
full
:
true
)
webUrl
}
}
}
}
app/assets/javascripts/boards/index.js
View file @
f79ed8ad
...
...
@@ -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
,
...
...
app/assets/javascripts/boards/stores/actions.js
View file @
f79ed8ad
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
();
},
...
...
app/assets/javascripts/boards/stores/mutation_types.js
View file @
f79ed8ad
...
...
@@ -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
'
;
app/assets/javascripts/boards/stores/mutations.js
View file @
f79ed8ad
...
...
@@ -309,4 +309,8 @@ export default {
[
mutationTypes
.
RESET_BOARD_ITEM_SELECTION
]:
(
state
)
=>
{
state
.
selectedBoardItems
=
[];
},
[
mutationTypes
.
SET_ERROR
]:
(
state
,
error
)
=>
{
state
.
error
=
error
;
},
};
app/assets/javascripts/boards/stores/state.js
View file @
f79ed8ad
...
...
@@ -34,4 +34,5 @@ export default () => ({
},
// TODO: remove after ce/ee split of board_content.vue
isShowingEpicsSwimlanes
:
false
,
activeBlockingIssuables
:
[],
});
app/assets/javascripts/graphql_shared/constants.js
0 → 100644
View file @
f79ed8ad
/* eslint-disable @gitlab/require-i18n-strings */
export
const
IssueType
=
'
Issue
'
;
changelogs/unreleased/implement-blocking-issue-popover-for-boards.yml
0 → 100644
View file @
f79ed8ad
---
title
:
Add blocked issues detail popover for boards cards
merge_request
:
55821
author
:
type
:
added
doc/user/project/img/issue_boards_blocked_icon_v13_10.png
0 → 100644
View file @
f79ed8ad
41.5 KB
doc/user/project/img/issue_boards_blocked_icon_v13_6.png
deleted
100644 → 0
View file @
1632d456
16.3 KB
doc/user/project/issue_board.md
View file @
f79ed8ad
...
...
@@ -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
...
...
ee/spec/frontend/boards/board_card_inner_spec.js
View file @
f79ed8ad
import
{
GlLabel
}
from
'
@gitlab/ui
'
;
import
{
shallowMount
}
from
'
@vue/test-utils
'
;
import
Vuex
from
'
vuex
'
;
import
IssueCardWeight
from
'
ee/boards/components/issue_card_weight.vue
'
;
import
BoardCardInner
from
'
~/boards/components/board_card_inner.vue
'
;
import
{
issuableTypes
}
from
'
~/boards/constants
'
;
import
defaultStore
from
'
~/boards/stores
'
;
describe
(
'
Board card component
'
,
()
=>
{
let
wrapper
;
let
issue
;
let
list
;
let
store
;
const
createComponent
=
(
props
=
{},
store
=
defaultStore
)
=>
{
const
createStore
=
({
isShowingLabels
=
true
}
=
{})
=>
{
store
=
new
Vuex
.
Store
({
...
defaultStore
,
state
:
{
...
defaultStore
.
state
,
issuableType
:
issuableTypes
.
issue
,
isShowingLabels
,
},
getters
:
{
isGroupBoard
:
()
=>
true
,
isEpicBoard
:
()
=>
false
,
isProjectBoard
:
()
=>
false
,
},
});
};
const
createComponent
=
(
props
=
{})
=>
{
wrapper
=
shallowMount
(
BoardCardInner
,
{
store
,
propsData
:
{
...
...
@@ -55,9 +74,14 @@ describe('Board card component', () => {
};
});
beforeEach
(()
=>
{
createStore
();
});
afterEach
(()
=>
{
wrapper
.
destroy
();
wrapper
=
null
;
store
=
null
;
});
describe
(
'
labels
'
,
()
=>
{
...
...
@@ -95,48 +119,13 @@ describe('Board card component', () => {
});
it
(
'
shows no labels when the isShowingLabels state is false
'
,
()
=>
{
const
store
=
{
...
defaultStore
,
state
:
{
...
defaultStore
.
state
,
isShowingLabels
:
false
,
},
};
createComponent
({},
store
);
createStore
({
isShowingLabels
:
false
});
createComponent
({});
expect
(
wrapper
.
findAll
(
'
.board-card-labels
'
)).
toHaveLength
(
0
);
});
});
describe
(
'
blocked
'
,
()
=>
{
const
findBlockedIcon
=
()
=>
wrapper
.
find
(
'
[data-testid="issue-blocked-icon"
'
);
it
(
'
shows blocked icon if issue is blocked, when blocked by multiple issues
'
,
()
=>
{
createComponent
();
const
blockedIcon
=
findBlockedIcon
();
expect
(
blockedIcon
.
exists
()).
toBe
(
true
);
expect
(
blockedIcon
.
attributes
(
'
title
'
)).
toBe
(
'
Blocked by 2 issues
'
);
});
it
(
'
shows blocked icon if issue is blocked, when blocked by one issue
'
,
()
=>
{
issue
.
blockedByCount
=
1
;
createComponent
();
const
blockedIcon
=
findBlockedIcon
();
expect
(
blockedIcon
.
exists
()).
toBe
(
true
);
expect
(
blockedIcon
.
attributes
(
'
title
'
)).
toBe
(
'
Blocked by 1 issue
'
);
});
it
(
'
does not show blocked icon if issue is not blocked
'
,
()
=>
{
issue
.
blocked
=
false
;
issue
.
blockedByCount
=
0
;
createComponent
();
expect
(
findBlockedIcon
().
exists
()).
toBe
(
false
);
});
});
describe
(
'
weight
'
,
()
=>
{
it
(
'
shows weight component
'
,
()
=>
{
createComponent
();
...
...
locale/gitlab.pot
View file @
f79ed8ad
...
...
@@ -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 ""
...
...
spec/frontend/boards/board_card_inner_spec.js
View file @
f79ed8ad
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
.
i
i
d
}
`
);
});
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
;
...
...
spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap
0 → 100644
View file @
f79ed8ad
// 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>"
`;
spec/frontend/boards/components/board_blocked_icon_spec.js
0 → 100644
View file @
f79ed8ad
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`
,
);
});
});
});
});
spec/frontend/boards/mock_data.js
View file @
f79ed8ad
...
...
@@ -398,3 +398,90 @@ export const mockActiveGroupProjects = [
{
...
mockGroupProject1
,
archived
:
false
},
{
...
mockGroupProject2
,
archived
:
false
},
];
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
'
,
};
spec/frontend/boards/stores/actions_spec.js
View file @
f79ed8ad
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
);
});
...
...
spec/frontend/boards/stores/mutations_spec.js
View file @
f79ed8ad
...
...
@@ -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
'
);
});
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment