Commit f79ed8ad authored by Eulyeon Ko's avatar Eulyeon Ko

Add blocked popover feature for boards

Also
- Add vuex action for setting error state
parent 1632d456
<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'; ...@@ -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 UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import { ListType } from '../constants'; import { ListType } from '../constants';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import BoardBlockedIcon from './board_blocked_icon.vue';
import IssueDueDate from './issue_due_date.vue'; import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue'; import IssueTimeEstimate from './issue_time_estimate.vue';
...@@ -22,6 +23,7 @@ export default { ...@@ -22,6 +23,7 @@ export default {
IssueDueDate, IssueDueDate,
IssueTimeEstimate, IssueTimeEstimate,
IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
BoardBlockedIcon,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -52,7 +54,7 @@ export default { ...@@ -52,7 +54,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['isShowingLabels']), ...mapState(['isShowingLabels', 'issuableType']),
...mapGetters(['isEpicBoard']), ...mapGetters(['isEpicBoard']),
cappedAssignees() { cappedAssignees() {
// e.g. maxRender is 4, // e.g. maxRender is 4,
...@@ -114,7 +116,13 @@ export default { ...@@ -114,7 +116,13 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['performSearch']), ...mapActions([
'performSearch',
'setError',
'setBlockingIssuables',
'unsetBlockingIssuables',
'toggleBlockedPopover',
]),
isIndexLessThanlimit(index) { isIndexLessThanlimit(index) {
return index < this.limitBeforeCounter; return index < this.limitBeforeCounter;
}, },
...@@ -164,14 +172,14 @@ export default { ...@@ -164,14 +172,14 @@ export default {
<div> <div>
<div class="gl-display-flex" dir="auto"> <div class="gl-display-flex" dir="auto">
<h4 class="board-card-title gl-mb-0 gl-mt-0"> <h4 class="board-card-title gl-mb-0 gl-mt-0">
<gl-icon <board-blocked-icon
v-if="item.blocked" v-if="item.blocked"
v-gl-tooltip :item="item"
name="issue-block" :unique-id="`${item.id}${list.id}`"
:title="blockedLabel" :issuable-type="issuableType"
class="issue-blocked-icon gl-mr-2" @blocking-issuables-error="setError"
:aria-label="blockedLabel" @blocking-issuables="setBlockingIssuables"
data-testid="issue-blocked-icon" @hidden="unsetBlockingIssuables"
/> />
<gl-icon <gl-icon
v-if="item.confidential" v-if="item.confidential"
...@@ -181,13 +189,9 @@ export default { ...@@ -181,13 +189,9 @@ export default {
class="confidential-icon gl-mr-2" class="confidential-icon gl-mr-2"
:aria-label="__('Confidential')" :aria-label="__('Confidential')"
/> />
<a <a :href="item.path || item.webUrl || ''" :title="item.title" @mousemove.stop>{{
:href="item.path || item.webUrl || ''" item.title
:title="item.title" }}</a>
class="js-no-trigger"
@mousemove.stop
>{{ item.title }}</a
>
</h4> </h4>
</div> </div>
<div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
......
...@@ -69,7 +69,7 @@ export default { ...@@ -69,7 +69,7 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['moveList']), ...mapActions(['moveList', 'unsetError']),
afterFormEnters() { afterFormEnters() {
const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
...@@ -100,7 +100,7 @@ export default { ...@@ -100,7 +100,7 @@ export default {
<template> <template>
<div> <div>
<gl-alert v-if="error" variant="danger" :dismissible="false"> <gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="unsetError">
{{ error }} {{ error }}
</gl-alert> </gl-alert>
<component <component
......
import { __ } from '~/locale'; import { __ } from '~/locale';
import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql';
export const issuableTypes = { export const issuableTypes = {
issue: 'issue', issue: 'issue',
...@@ -45,3 +46,9 @@ export default { ...@@ -45,3 +46,9 @@ export default {
BoardType, BoardType,
ListType, ListType,
}; };
export const blockingIssuablesQueries = {
[issuableTypes.issue]: {
query: boardBlockingIssuesQuery,
},
};
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 () => { ...@@ -107,6 +107,7 @@ export default () => {
milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable), milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable),
assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable), assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable),
iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable), iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable),
issuableType: issuableTypes.issue,
}, },
store, store,
apolloProvider, apolloProvider,
......
import * as Sentry from '@sentry/browser';
import { pick } from 'lodash'; import { pick } from 'lodash';
import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; 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'; import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
...@@ -608,6 +609,18 @@ export default { ...@@ -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: () => { fetchBacklog: () => {
notImplemented(); notImplemented();
}, },
......
...@@ -49,3 +49,4 @@ export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE'; ...@@ -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 ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_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 RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
export const SET_ERROR = 'SET_ERROR';
...@@ -309,4 +309,8 @@ export default { ...@@ -309,4 +309,8 @@ export default {
[mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => { [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
state.selectedBoardItems = []; state.selectedBoardItems = [];
}, },
[mutationTypes.SET_ERROR]: (state, error) => {
state.error = error;
},
}; };
...@@ -34,4 +34,5 @@ export default () => ({ ...@@ -34,4 +34,5 @@ export default () => ({
}, },
// TODO: remove after ce/ee split of board_content.vue // TODO: remove after ce/ee split of board_content.vue
isShowingEpicsSwimlanes: false, isShowingEpicsSwimlanes: false,
activeBlockingIssuables: [],
}); });
/* eslint-disable @gitlab/require-i18n-strings */
export const IssueType = 'Issue';
---
title: Add blocked issues detail popover for boards cards
merge_request: 55821
author:
type: added
...@@ -280,6 +280,7 @@ group-level objects are available. ...@@ -280,6 +280,7 @@ group-level objects are available.
#### GraphQL-based sidebar for group issue boards **(PREMIUM)** #### GraphQL-based sidebar for group issue boards **(PREMIUM)**
<!-- When the feature flag is removed, integrate this section into the above ("Group issue boards"). --> <!-- 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. > - [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. > - 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: ...@@ -407,12 +408,18 @@ To set a WIP limit for a list:
## Blocked issues ## 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 If an issue is blocked by another issue, an icon appears next to its title to indicate its blocked
status. 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 ## Actions you can take on an issue board
......
import { GlLabel } from '@gitlab/ui'; import { GlLabel } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import IssueCardWeight from 'ee/boards/components/issue_card_weight.vue'; import IssueCardWeight from 'ee/boards/components/issue_card_weight.vue';
import BoardCardInner from '~/boards/components/board_card_inner.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue';
import { issuableTypes } from '~/boards/constants';
import defaultStore from '~/boards/stores'; import defaultStore from '~/boards/stores';
describe('Board card component', () => { describe('Board card component', () => {
let wrapper; let wrapper;
let issue; let issue;
let list; 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, { wrapper = shallowMount(BoardCardInner, {
store, store,
propsData: { propsData: {
...@@ -55,9 +74,14 @@ describe('Board card component', () => { ...@@ -55,9 +74,14 @@ describe('Board card component', () => {
}; };
}); });
beforeEach(() => {
createStore();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
store = null;
}); });
describe('labels', () => { describe('labels', () => {
...@@ -95,48 +119,13 @@ describe('Board card component', () => { ...@@ -95,48 +119,13 @@ describe('Board card component', () => {
}); });
it('shows no labels when the isShowingLabels state is false', () => { it('shows no labels when the isShowingLabels state is false', () => {
const store = { createStore({ isShowingLabels: false });
...defaultStore, createComponent({});
state: {
...defaultStore.state,
isShowingLabels: false,
},
};
createComponent({}, store);
expect(wrapper.findAll('.board-card-labels')).toHaveLength(0); 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', () => { describe('weight', () => {
it('shows weight component', () => { it('shows weight component', () => {
createComponent(); createComponent();
......
...@@ -4880,6 +4880,11 @@ msgstr "" ...@@ -4880,6 +4880,11 @@ msgstr ""
msgid "Boards and Board Lists" msgid "Boards and Board Lists"
msgstr "" 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." msgid "Boards|An error occurred while creating the issue. Please try again."
msgstr "" msgstr ""
...@@ -4922,6 +4927,11 @@ msgstr "" ...@@ -4922,6 +4927,11 @@ msgstr ""
msgid "Boards|An error occurred while updating the list. Please try again." msgid "Boards|An error occurred while updating the list. Please try again."
msgstr "" msgstr ""
msgid "Boards|Blocked by %{blockedByCount} %{issuableType}"
msgid_plural "Boards|Blocked by %{blockedByCount} %{issuableType}s"
msgstr[0] ""
msgstr[1] ""
msgid "Boards|Board" msgid "Boards|Board"
msgstr "" msgstr ""
...@@ -4934,6 +4944,15 @@ msgstr "" ...@@ -4934,6 +4944,15 @@ msgstr ""
msgid "Boards|Expand" msgid "Boards|Expand"
msgstr "" 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" msgid "Boards|View scope"
msgstr "" msgstr ""
......
import { GlLabel } from '@gitlab/ui'; import { GlLabel } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { range } from 'lodash'; 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 BoardCardInner from '~/boards/components/board_card_inner.vue';
import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import defaultStore from '~/boards/stores'; import defaultStore from '~/boards/stores';
import { updateHistory } from '~/lib/utils/url_utility'; 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('~/lib/utils/url_utility');
jest.mock('~/boards/eventhub'); jest.mock('~/boards/eventhub');
...@@ -29,8 +32,28 @@ describe('Board card component', () => { ...@@ -29,8 +32,28 @@ describe('Board card component', () => {
let wrapper; let wrapper;
let issue; let issue;
let list; 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, { wrapper = mount(BoardCardInner, {
store, store,
propsData: { propsData: {
...@@ -41,6 +64,13 @@ describe('Board card component', () => { ...@@ -41,6 +64,13 @@ describe('Board card component', () => {
stubs: { stubs: {
GlLabel: true, GlLabel: true,
}, },
mocks: {
$apollo: {
queries: {
blockingIssuables: { loading: false },
},
},
},
provide: { provide: {
rootPath: '/', rootPath: '/',
scopedLabelsAvailable: false, scopedLabelsAvailable: false,
...@@ -51,14 +81,9 @@ describe('Board card component', () => { ...@@ -51,14 +81,9 @@ describe('Board card component', () => {
beforeEach(() => { beforeEach(() => {
list = mockLabelList; list = mockLabelList;
issue = { issue = {
title: 'Testing', ...mockIssue,
id: 1,
iid: 1,
confidential: false,
labels: [list.label], labels: [list.label],
assignees: [], assignees: [],
referencePath: '#1',
webUrl: '/test/1',
weight: 1, weight: 1,
}; };
...@@ -68,6 +93,7 @@ describe('Board card component', () => { ...@@ -68,6 +93,7 @@ describe('Board card component', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
store = null;
jest.clearAllMocks(); jest.clearAllMocks();
}); });
...@@ -87,18 +113,38 @@ describe('Board card component', () => { ...@@ -87,18 +113,38 @@ describe('Board card component', () => {
expect(wrapper.find('.confidential-icon').exists()).toBe(false); 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 #', () => { 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', () => { it('does not render assignee', () => {
expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false); 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', () => { describe('confidential issue', () => {
beforeEach(() => { beforeEach(() => {
wrapper.setProps({ wrapper.setProps({
...@@ -303,21 +349,6 @@ describe('Board card component', () => { ...@@ -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', () => { describe('filterByLabel method', () => {
beforeEach(() => { beforeEach(() => {
delete window.location; delete window.location;
......
// 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>"
`;
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`,
);
});
});
});
});
...@@ -398,3 +398,90 @@ export const mockActiveGroupProjects = [ ...@@ -398,3 +398,90 @@ export const mockActiveGroupProjects = [
{ ...mockGroupProject1, archived: false }, { ...mockGroupProject1, archived: false },
{ ...mockGroupProject2, 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',
};
import * as Sentry from '@sentry/browser';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { import {
fullBoardId, fullBoardId,
...@@ -1378,6 +1379,51 @@ describe('toggleBoardItem', () => { ...@@ -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', () => { describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog); expectNotImplemented(actions.fetchBacklog);
}); });
......
...@@ -666,4 +666,14 @@ describe('Board Store Mutations', () => { ...@@ -666,4 +666,14 @@ describe('Board Store Mutations', () => {
expect(state.selectedBoardItems).toEqual([]); 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');
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment