Commit 56139a7a authored by Mike Greiling's avatar Mike Greiling

Merge branch 'implement-blocking-issue-popover-for-boards' into 'master'

Implement blocking issue popover for boards

See merge request gitlab-org/gitlab!55821
parents 7676c7eb 92eed576
<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">
......
......@@ -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
......
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,
},
};
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,
......
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;
},
};
/* 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.
#### 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
......
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();
......
......@@ -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 ""
......
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;
......
// 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 = [
{ ...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',
};
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');
});
});
});
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