Commit dd90e927 authored by Coung Ngo's avatar Coung Ngo Committed by Olena Horal-Koretska

Improve iteration report group by label UI

- Adds issue's labels to label grouping list
- Adds light gray background to label grouping list
- Fixes scoped label rendering
- Adds page specific CSS to override GlLabel style in GlAlert

Discussion of UI changes:
https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51113#note_485816203
parent 372293b4
......@@ -184,7 +184,6 @@ module Gitlab
config.assets.precompile << "page_bundles/build.css"
config.assets.precompile << "page_bundles/ci_status.css"
config.assets.precompile << "page_bundles/cycle_analytics.css"
config.assets.precompile << "page_bundles/security_discover.css"
config.assets.precompile << "page_bundles/dev_ops_report.css"
config.assets.precompile << "page_bundles/environments.css"
config.assets.precompile << "page_bundles/epics.css"
......@@ -194,6 +193,7 @@ module Gitlab
config.assets.precompile << "page_bundles/import.css"
config.assets.precompile << "page_bundles/incident_management_list.css"
config.assets.precompile << "page_bundles/issues_list.css"
config.assets.precompile << "page_bundles/iterations.css"
config.assets.precompile << "page_bundles/jira_connect.css"
config.assets.precompile << "page_bundles/jira_connect_users.css"
config.assets.precompile << "page_bundles/merge_conflicts.css"
......@@ -208,6 +208,7 @@ module Gitlab
config.assets.precompile << "page_bundles/reports.css"
config.assets.precompile << "page_bundles/roadmap.css"
config.assets.precompile << "page_bundles/security_dashboard.css"
config.assets.precompile << "page_bundles/security_discover.css"
config.assets.precompile << "page_bundles/signup.css"
config.assets.precompile << "page_bundles/terminal.css"
config.assets.precompile << "page_bundles/todos.css"
......
......@@ -69,6 +69,11 @@ export default {
type: String,
required: true,
},
hasScopedLabelsFeature: {
type: Boolean,
required: false,
default: false,
},
iterationId: {
type: String,
required: false,
......@@ -100,6 +105,11 @@ export default {
required: false,
default: '',
},
svgPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -226,9 +236,11 @@ export default {
/>
<iteration-report-tabs
:full-path="fullPath"
:has-scoped-labels-feature="hasScopedLabelsFeature"
:iteration-id="iteration.id"
:labels-fetch-path="labelsFetchPath"
:namespace-type="namespaceType"
:svg-path="svgPath"
/>
</template>
</div>
......
......@@ -12,7 +12,8 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, sprintf } from '~/locale';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { __, n__, sprintf } from '~/locale';
import { Namespace } from '../constants';
import iterationIssuesQuery from '../queries/iteration_issues.query.graphql';
import iterationIssuesWithLabelFilterQuery from '../queries/iteration_issues_with_label_filter.query.graphql';
......@@ -80,7 +81,10 @@ export default {
};
},
result({ data }) {
this.$emit('issueCount', data[this.namespaceType]?.issues?.count);
this.$emit('issuesUpdate', {
count: data[this.namespaceType]?.issues?.count,
labelId: this.label?.id,
});
},
error() {
this.error = __('Error loading issues');
......@@ -92,6 +96,11 @@ export default {
type: String,
required: true,
},
hasScopedLabelsFeature: {
type: Boolean,
required: false,
default: false,
},
iterationId: {
type: String,
required: true,
......@@ -129,7 +138,7 @@ export default {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
},
accordionName() {
return this.isExpanded ? __('Collapse') : __('Expand');
return this.isExpanded ? __('Collapse issues') : __('Expand issues');
},
pageSize() {
const labelGroupingPageSize = 5;
......@@ -163,6 +172,17 @@ export default {
nextPage() {
return Number(this.issues.pageInfo.hasNextPage);
},
sectionName() {
return this.label.title
? sprintf(__('Issues with label %{label}'), { label: this.label.title })
: __('Issues');
},
badgeAriaLabel() {
return n__('%d issue', '%d issues', this.issues.count);
},
tbodyTrClass() {
return this.label.title ? 'gl-bg-gray-10' : undefined;
},
},
methods: {
tooltipText(assignee) {
......@@ -194,6 +214,9 @@ export default {
};
}
},
shouldShowScopedLabel(label) {
return this.hasScopedLabelsFeature && isScopedLabel(label);
},
toggleIsExpanded() {
this.isExpanded = !this.isExpanded;
},
......@@ -202,12 +225,12 @@ export default {
</script>
<template>
<div>
<section :aria-label="sectionName">
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">
{{ error }}
</gl-alert>
<div v-if="label.title" class="gl-display-flex gl-align-items-center">
<div v-if="label.title" class="gl-display-flex gl-align-items-center gl-mb-2">
<gl-button
category="tertiary"
:icon="accordionIcon"
......@@ -218,10 +241,11 @@ export default {
class="gl-ml-1"
:background-color="label.color"
:description="label.description"
:scoped="label.scoped"
:scoped="shouldShowScopedLabel(label)"
:target="null"
:title="label.title"
/>
<gl-badge class="gl-ml-2" size="sm" variant="muted">
<gl-badge class="gl-ml-2" size="sm" variant="muted" :aria-label="badgeAriaLabel">
{{ issues.count }}
</gl-badge>
</div>
......@@ -235,20 +259,34 @@ export default {
:show-empty="true"
fixed
stacked="sm"
:tbody-tr-class="tbodyTrClass"
data-qa-selector="iteration_issues_container"
>
<template #cell(title)="{ item: { iid, title, webUrl } }">
<template #cell(title)="{ item: { iid, labels, title, webUrl } }">
<div class="gl-text-truncate">
<gl-link
class="gl-text-gray-900 gl-font-weight-bold"
:href="webUrl"
:title="title"
data-qa-selector="iteration_issue_link"
:data-qa-issue-title="title"
>{{ title }}
</gl-link>
<!-- TODO: add references.relative (project name) -->
<!-- Depends on https://gitlab.com/gitlab-org/gitlab/-/issues/222763 -->
<div class="gl-text-secondary">#{{ iid }}</div>
</div>
<!-- TODO: add references.relative (project name) -->
<!-- Depends on https://gitlab.com/gitlab-org/gitlab/-/issues/222763 -->
<div class="gl-text-secondary">#{{ iid }}</div>
<div role="group" :aria-label="__('Labels')">
<gl-label
v-for="l in labels"
:key="l.id"
class="gl-mt-2 gl-mr-2"
:background-color="l.color"
:description="l.description"
:scoped="shouldShowScopedLabel(l)"
:target="null"
:title="l.title"
/>
</div>
</template>
......@@ -278,5 +316,5 @@ export default {
@input="handlePageChange"
/>
</div>
</div>
</section>
</template>
<script>
import { GlBadge, GlFormSelect, GlTab, GlTabs } from '@gitlab/ui';
import { GlAlert, GlBadge, GlEmptyState, GlFormSelect, GlLabel, GlTab, GlTabs } from '@gitlab/ui';
import { differenceBy, unionBy } from 'lodash';
import Vue from 'vue';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
......@@ -20,8 +22,11 @@ export default {
],
variant: DropdownVariant.Standalone,
components: {
GlAlert,
GlBadge,
GlEmptyState,
GlFormSelect,
GlLabel,
GlTab,
GlTabs,
IterationReportIssues,
......@@ -32,6 +37,11 @@ export default {
type: String,
required: true,
},
hasScopedLabelsFeature: {
type: Boolean,
required: false,
default: false,
},
iterationId: {
type: String,
required: true,
......@@ -47,6 +57,11 @@ export default {
default: Namespace.Group,
validator: (value) => Object.values(Namespace).includes(value),
},
svgPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -59,17 +74,38 @@ export default {
shouldShowFilterByLabel() {
return this.groupBySelection === GroupBy.Label;
},
showEmptyState() {
return this.selectedLabels.length && !this.labelsWithIssues.length;
},
labelsWithIssues() {
return this.selectedLabels.filter((label) => label.hasIssues);
},
labelsWithoutIssues() {
return this.selectedLabels.filter((label) => !label.hasIssues);
},
},
methods: {
handleIssueCount(count) {
this.issueCount = count;
handleIssuesUpdate({ count, labelId }) {
if (!labelId) {
this.issueCount = count;
return;
}
const index = this.selectedLabels.findIndex((l) => l.id === labelId);
if (index > -1) {
const label = this.selectedLabels[index];
label.hasIssues = Boolean(count);
Vue.set(this.selectedLabels, index, label);
}
},
handleSelectChange() {
if (this.groupBySelection === GroupBy.None) {
this.selectedLabels = [];
}
},
handleUpdateSelectedLabels(labels) {
handleUpdateSelectedLabels(selectedLabels) {
const labels = selectedLabels.map((label) => ({ ...label, hasIssues: true }));
const labelsToAdd = labels.filter((label) => label.set);
const labelsToRemove = labels.filter((label) => !label.set);
const idProperty = 'id';
......@@ -80,6 +116,9 @@ export default {
idProperty,
);
},
shouldShowScopedLabel(label) {
return this.hasScopedLabelsFeature && isScopedLabel(label);
},
},
};
</script>
......@@ -93,7 +132,7 @@ export default {
</template>
<div class="card gl-bg-gray-10 gl-display-flex gl-flex-direction-row gl-flex-wrap gl-px-4">
<div class="gl-my-3">
<div class="gl-my-3 gl-mr-4">
<label for="iteration-group-by">{{ __('Group by') }}</label>
<gl-form-select
id="iteration-group-by"
......@@ -106,14 +145,14 @@ export default {
<div
v-if="shouldShowFilterByLabel"
class="gl-display-flex gl-align-items-center gl-flex-basis-half gl-white-space-nowrap gl-my-3 gl-ml-4"
class="gl-display-flex gl-align-items-center gl-flex-basis-half gl-white-space-nowrap gl-my-3"
>
<label class="gl-mb-0 gl-mr-2">{{ __('Filter by label') }}</label>
<labels-select
:allow-label-create="false"
:allow-label-edit="true"
:allow-multiselect="true"
:allow-scoped-labels="true"
:allow-scoped-labels="hasScopedLabelsFeature"
:labels-fetch-path="labelsFetchPath"
:selected-labels="selectedLabels"
:variant="$options.variant"
......@@ -122,23 +161,45 @@ export default {
</div>
</div>
<gl-alert v-if="labelsWithoutIssues.length" class="gl-mb-4" :dismissible="false">
{{ __('Labels with no issues in this iteration:') }}
<gl-label
v-for="label in labelsWithoutIssues"
:key="label.id"
class="gl-ml-1 gl-vertical-align-middle"
:background-color="label.color"
:description="label.description"
:scoped="shouldShowScopedLabel(label)"
:target="null"
:title="label.title"
/>
</gl-alert>
<gl-empty-state
v-if="showEmptyState"
:svg-path="svgPath"
:title="__('No issues found for the selected labels')"
/>
<iteration-report-issues
v-for="label in selectedLabels"
v-for="label in labelsWithIssues"
:key="label.id"
class="gl-mb-6"
:full-path="fullPath"
:has-scoped-labels-feature="hasScopedLabelsFeature"
:iteration-id="iterationId"
:label="label"
:namespace-type="namespaceType"
:data-testid="`iteration-label-group-${label.id}`"
@issuesUpdate="handleIssuesUpdate"
/>
<iteration-report-issues
v-show="!selectedLabels.length"
:full-path="fullPath"
:has-scoped-labels-feature="hasScopedLabelsFeature"
:iteration-id="iterationId"
:namespace-type="namespaceType"
@issueCount="handleIssueCount"
@issuesUpdate="handleIssuesUpdate"
/>
</gl-tab>
</gl-tabs>
......
......@@ -59,10 +59,12 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) {
const {
fullPath,
hasScopedLabelsFeature,
iterationId,
labelsFetchPath,
editIterationPath,
previewMarkdownPath,
svgPath,
} = el.dataset;
const canEdit = parseBoolean(el.dataset.canEdit);
......@@ -73,12 +75,14 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) {
return createElement(IterationReport, {
props: {
fullPath,
hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature),
iterationId,
labelsFetchPath,
canEdit,
editIterationPath,
namespaceType,
previewMarkdownPath,
svgPath,
initiallyEditing,
},
});
......
#import "./iteration_issues.fragment.graphql"
#import "./iteration_issues_with_labels.fragment.graphql"
query IterationIssuesWithLabelFilter(
$fullPath: ID!
......@@ -20,7 +20,7 @@ query IterationIssuesWithLabelFilter(
last: $lastPageSize
includeSubgroups: true
) {
...IterationIssues
...IterationIssuesWithLabels
}
}
project(fullPath: $fullPath) @skip(if: $isGroup) {
......@@ -32,7 +32,7 @@ query IterationIssuesWithLabelFilter(
first: $firstPageSize
last: $lastPageSize
) {
...IterationIssues
...IterationIssuesWithLabels
}
}
}
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
fragment IterationIssuesWithLabels on IssueConnection {
count
pageInfo {
...PageInfo
}
nodes {
iid
labels {
nodes {
...Label
}
}
title
webUrl
state
assignees {
nodes {
...User
}
}
}
}
/* TODO Remove this CSS page bundle once this CSS override has been added to GitLab UI */
.gl-alert .gl-label .gl-link {
text-decoration: none;
}
- add_to_breadcrumbs _("Iterations"), group_iterations_path(@group)
- breadcrumb_title params[:id]
- page_title _("Iterations")
- add_page_specific_style 'page_bundles/iterations'
- if Feature.enabled?(:group_iterations, @group, default_enabled: true)
.js-iteration{ data: { full_path: @group.full_path,
can_edit: can?(current_user, :admin_iteration, @group).to_s,
has_scoped_labels_feature: @group.feature_available?(:scoped_labels).to_s,
iteration_id: params[:id],
labels_fetch_path: group_labels_path(@group, format: :json, include_ancestor_groups: true),
preview_markdown_path: preview_markdown_path(@group) } }
preview_markdown_path: preview_markdown_path(@group),
svg_path: image_path('illustrations/issues.svg') } }
- add_to_breadcrumbs _("Iterations"), project_iterations_path(@project)
- breadcrumb_title params[:id]
- page_title _("Iteration")
- add_page_specific_style 'page_bundles/iterations'
.js-iteration{ data: { full_path: @project.full_path,
can_edit: can?(current_user, :admin_iteration, @project).to_s,
has_scoped_labels_feature: @project.feature_available?(:scoped_labels).to_s,
iteration_id: params[:id],
labels_fetch_path: project_labels_path(@project, format: :json, include_ancestor_groups: true),
preview_markdown_path: preview_markdown_path(@project) } }
preview_markdown_path: preview_markdown_path(@project),
svg_path: image_path('illustrations/issues.svg') } }
......@@ -28,12 +28,12 @@ describe('Iterations report issues', () => {
iterationId: `gid://gitlab/Iteration/${id}`,
};
const findGlBadge = () => wrapper.find(GlBadge);
const findGlButton = () => wrapper.find(GlButton);
const findGlLabel = () => wrapper.find(GlLabel);
const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findGlPagination = () => wrapper.find(GlPagination);
const findGlTable = () => wrapper.find(GlTable);
const findGlBadge = () => wrapper.findComponent(GlBadge);
const findGlButton = () => wrapper.findComponent(GlButton);
const findGlLabel = () => wrapper.findComponent(GlLabel);
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findGlPagination = () => wrapper.findComponent(GlPagination);
const findGlTable = () => wrapper.findComponent(GlTable);
const mountComponent = ({
props = defaultProps,
......@@ -85,7 +85,7 @@ describe('Iterations report issues', () => {
},
});
expect(wrapper.find(GlAlert).text()).toContain(error);
expect(wrapper.findComponent(GlAlert).text()).toContain(error);
});
describe('with issues', () => {
......@@ -103,16 +103,28 @@ describe('Iterations report issues', () => {
webUrl: `https://localhost:3000/user${i}`,
}));
const labels = Array(2)
.fill(null)
.map((_, i) => ({
id: i,
color: '#000',
description: `Label ${i} description`,
text_color: '#fff',
title: `Label ${i}`,
}));
const issues = Array(totalIssues)
.fill(null)
.map((_, i) => ({
id: i,
title: `Issue ${i}`,
assignees: assignees.slice(0, i),
labels,
}));
const findIssues = () => wrapper.findAll('table tbody tr');
const findAssigneesForIssue = (index) => findIssues().at(index).findAll(GlAvatar);
const findAssigneesForIssue = (index) => findIssues().at(index).findAllComponents(GlAvatar);
const findLabelsForIssue = (index) => findIssues().at(index).findAllComponents(GlLabel);
describe('issue_list', () => {
beforeEach(() => {
......@@ -137,6 +149,13 @@ describe('Iterations report issues', () => {
expect(findIssues()).toHaveLength(issues.length);
});
it('shows labels', () => {
const labelsForFirstIssue = findLabelsForIssue(0);
expect(labelsForFirstIssue).toHaveLength(2);
expect(labelsForFirstIssue.at(0).props('title')).toBe(labels[0].title);
expect(labelsForFirstIssue.at(1).props('title')).toBe(labels[1].title);
});
it('shows assignees', () => {
expect(findAssigneesForIssue(0)).toHaveLength(0);
expect(findAssigneesForIssue(1)).toHaveLength(1);
......@@ -162,7 +181,7 @@ describe('Iterations report issues', () => {
mountComponent({ data });
});
const findPagination = () => wrapper.find(GlPagination);
const findPagination = () => wrapper.findComponent(GlPagination);
const setPage = (page) => {
findPagination().vm.$emit('input', page);
return findPagination().vm.$nextTick();
......@@ -246,54 +265,71 @@ describe('Iterations report issues', () => {
});
});
describe('label grouping header', () => {
describe('when a label is provided', () => {
const count = 4;
describe('when a label is provided', () => {
const count = 4;
beforeEach(() => {
mountComponent({
props: { ...defaultProps, label },
data: { issues: { count } },
});
beforeEach(() => {
mountComponent({
props: { ...defaultProps, label },
data: { issues: { count } },
});
});
it('shows button to expand/collapse the table', () => {
expect(findGlButton().props('icon')).toBe('chevron-down');
expect(findGlButton().attributes('aria-label')).toBe('Collapse');
});
it('has section name which mentions the label', () => {
expect(wrapper.find('section').attributes('aria-label')).toBe(
`Issues with label ${label.title}`,
);
});
it('shows label with the label title', () => {
expect(findGlLabel().props()).toEqual(
expect.objectContaining({
backgroundColor: label.color,
description: label.description,
scoped: label.scoped,
title: label.title,
}),
);
});
it('shows button to expand/collapse the table', () => {
expect(findGlButton().props('icon')).toBe('chevron-down');
expect(findGlButton().attributes('aria-label')).toBe('Collapse issues');
});
it('shows badge with issue count', () => {
expect(findGlBadge().text()).toBe(count.toString());
});
it('shows label with the label title', () => {
expect(findGlLabel().props()).toEqual(
expect.objectContaining({
backgroundColor: label.color,
description: label.description,
target: null,
title: label.title,
}),
);
});
describe('when a label is not provided', () => {
beforeEach(() => {
mountComponent();
});
it('shows badge with issue count', () => {
expect(findGlBadge().text()).toBe(count.toString());
expect(findGlBadge().attributes('aria-label')).toBe(`${count} issues`);
});
it('hides button to expand/collapse the table', () => {
expect(findGlButton().exists()).toBe(false);
});
it('shows table with grey background', () => {
expect(findGlTable().attributes('tbody-tr-class')).toBe('gl-bg-gray-10');
});
});
it('hides label with the label title', () => {
expect(findGlLabel().exists()).toBe(false);
});
describe('when a label is not provided', () => {
beforeEach(() => {
mountComponent();
});
it('hides badge with issue count', () => {
expect(findGlBadge().exists()).toBe(false);
});
it('has section name which does not mention a label', () => {
expect(wrapper.find('section').attributes('aria-label')).toBe('Issues');
});
it('hides button to expand/collapse the table', () => {
expect(findGlButton().exists()).toBe(false);
});
it('hides label with the label title', () => {
expect(findGlLabel().exists()).toBe(false);
});
it('hides badge with issue count', () => {
expect(findGlBadge().exists()).toBe(false);
});
it('does not show table with grey background', () => {
expect(findGlTable().attributes('tbody-tr-class')).toBeUndefined();
});
});
......@@ -308,14 +344,14 @@ describe('Iterations report issues', () => {
it('hides the issues when the `Collapse` button is clicked', async () => {
expect(findGlButton().props('icon')).toBe('chevron-down');
expect(findGlButton().attributes('aria-label')).toBe('Collapse');
expect(findGlButton().attributes('aria-label')).toBe('Collapse issues');
expect(findGlTable().isVisible()).toBe(true);
expect(findGlPagination().isVisible()).toBe(true);
await findGlButton().vm.$emit('click');
expect(findGlButton().props('icon')).toBe('chevron-right');
expect(findGlButton().attributes('aria-label')).toBe('Expand');
expect(findGlButton().attributes('aria-label')).toBe('Expand issues');
expect(findGlTable().isVisible()).toBe(false);
expect(findGlPagination().isVisible()).toBe(false);
});
......@@ -331,14 +367,14 @@ describe('Iterations report issues', () => {
it('shows the issues when the `Expand` button is clicked', async () => {
expect(findGlButton().props('icon')).toBe('chevron-right');
expect(findGlButton().attributes('aria-label')).toBe('Expand');
expect(findGlButton().attributes('aria-label')).toBe('Expand issues');
expect(findGlTable().isVisible()).toBe(false);
expect(findGlPagination().isVisible()).toBe(false);
await findGlButton().vm.$emit('click');
expect(findGlButton().props('icon')).toBe('chevron-down');
expect(findGlButton().attributes('aria-label')).toBe('Collapse');
expect(findGlButton().attributes('aria-label')).toBe('Collapse issues');
expect(findGlTable().isVisible()).toBe(true);
expect(findGlPagination().isVisible()).toBe(true);
});
......
......@@ -28,8 +28,11 @@ describe('Iterations report', () => {
const findActionsDropdown = () => wrapper.find('[data-testid="actions-dropdown"]');
const clickEditButton = () => {
findActionsDropdown().vm.$emit('click');
wrapper.find(GlDropdownItem).vm.$emit('click');
wrapper.findComponent(GlDropdownItem).vm.$emit('click');
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findIterationForm = () => wrapper.findComponent(IterationForm);
const mountComponentWithApollo = ({
props = defaultProps,
......@@ -129,7 +132,7 @@ describe('Iterations report', () => {
loading: true,
});
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(findLoadingIcon().exists()).toBe(true);
});
describe('empty state', () => {
......@@ -138,8 +141,7 @@ describe('Iterations report', () => {
loading: false,
});
expect(wrapper.find(GlEmptyState).exists()).toBe(true);
expect(wrapper.find(GlEmptyState).props('title')).toEqual('Could not find iteration');
expect(findEmptyState().props('title')).toBe('Could not find iteration');
expect(findTitle().exists()).toBe(false);
expect(findDescription().exists()).toBe(false);
expect(findActionsDropdown().exists()).toBe(false);
......@@ -174,8 +176,8 @@ describe('Iterations report', () => {
});
it('hides empty region and loading spinner', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find(GlEmptyState).exists()).toBe(false);
expect(findLoadingIcon().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
});
it('shows title and description', () => {
......@@ -188,9 +190,9 @@ describe('Iterations report', () => {
});
it('shows IterationReportTabs component', () => {
const iterationReportTabs = wrapper.find(IterationReportTabs);
const iterationReportTabs = wrapper.findComponent(IterationReportTabs);
expect(iterationReportTabs.props()).toEqual({
expect(iterationReportTabs.props()).toMatchObject({
fullPath: defaultProps.fullPath,
iterationId: iteration.id,
labelsFetchPath: defaultProps.labelsFetchPath,
......@@ -248,7 +250,7 @@ describe('Iterations report', () => {
it('updates URL when cancelling form submit', async () => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
wrapper.find(IterationForm).vm.$emit('cancel');
findIterationForm().vm.$emit('cancel');
await wrapper.vm.$nextTick();
......@@ -261,7 +263,7 @@ describe('Iterations report', () => {
it('updates URL after form submitted', async () => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
wrapper.find(IterationForm).vm.$emit('updated');
findIterationForm().vm.$emit('updated');
await wrapper.vm.$nextTick();
......@@ -299,7 +301,7 @@ describe('Iterations report', () => {
});
it(`${canEditIteration ? 'is shown' : 'is hidden'}`, () => {
expect(wrapper.find(GlDropdown).exists()).toBe(canEditIteration);
expect(wrapper.findComponent(GlDropdown).exists()).toBe(canEditIteration);
});
},
);
......
import { GlBadge, GlFormSelect } from '@gitlab/ui';
import { GlAlert, GlBadge, GlEmptyState, GlFormSelect, GlLabel } from '@gitlab/ui';
import { getByText } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
......@@ -18,9 +18,14 @@ describe('Iterations report tabs', () => {
};
const findGlFormSelectOptionAt = (index) =>
wrapper.find(GlFormSelect).findAll('option').at(index);
const findIterationReportIssuesAt = (index) => wrapper.findAll(IterationReportIssues).at(index);
const findLabelsSelect = () => wrapper.find(LabelsSelect);
wrapper.findComponent(GlFormSelect).findAll('option').at(index);
const findNoIssuesAlert = () => wrapper.findComponent(GlAlert);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findIterationReportIssues = () => wrapper.findComponent(IterationReportIssues);
const findAllIterationReportIssues = () => wrapper.findAllComponents(IterationReportIssues);
const findIterationReportIssuesAt = (index) =>
wrapper.findAllComponents(IterationReportIssues).at(index);
const findLabelsSelect = () => wrapper.findComponent(LabelsSelect);
const mountComponent = ({
props = defaultProps,
......@@ -50,19 +55,19 @@ describe('Iterations report tabs', () => {
it('is rendered', () => {
mountComponent();
expect(wrapper.find(IterationReportIssues).isVisible()).toBe(true);
expect(findIterationReportIssues().isVisible()).toBe(true);
});
it('updates the issue count when issueCount is emitted', async () => {
it('updates the issue count when issuesUpdate is emitted', async () => {
mountComponent({ mountFunction: mount });
const issueCount = 7;
wrapper.find(IterationReportIssues).vm.$emit('issueCount', issueCount);
findIterationReportIssues().vm.$emit('issuesUpdate', { count: issueCount });
await nextTick();
expect(wrapper.find(GlBadge).text()).toBe(issueCount.toString());
expect(wrapper.findComponent(GlBadge).text()).toBe(issueCount.toString());
});
});
......@@ -110,41 +115,66 @@ describe('Iterations report tabs', () => {
});
describe('issues grouped by labels', () => {
const selectedLabels = [
{
id: 40,
title: 'Security',
color: '#aaa',
description: 'Security description',
text_color: '#fff',
set: true,
},
{
id: 55,
title: 'Tooling',
color: '#bbb',
description: 'Tooling description',
text_color: '#eee',
set: true,
},
];
beforeEach(() => {
mountComponent({ data: { groupBySelection: GroupBy.Label } });
});
describe('when labels are selected', () => {
const selectedLabels = [
{
id: 40,
title: 'Security',
color: '#ddd',
text_color: '#fff',
set: true,
},
{
id: 55,
title: 'Tooling',
color: '#ddd',
text_color: '#fff',
set: true,
},
];
describe('when labels with issues are selected', () => {
beforeEach(() => {
// User groups issues by 2 labels
findLabelsSelect().vm.$emit('updateSelectedLabels', selectedLabels);
// API call updates for the 2 labels are emitted
findIterationReportIssues().vm.$emit('issuesUpdate', {
count: 3,
labelId: selectedLabels[0].id,
});
findIterationReportIssues().vm.$emit('issuesUpdate', {
count: 2,
labelId: selectedLabels[1].id,
});
});
it('does not show an alert', () => {
expect(findNoIssuesAlert().exists()).toBe(false);
});
it('does not show empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
it('shows 3 IterationReportIssues blocks (one for `Security`, one for `Tooling`, and one for the hidden ungrouped list)', () => {
expect(findAllIterationReportIssues()).toHaveLength(3);
});
it('shows issues for `Security` label', () => {
expect(findIterationReportIssuesAt(0).props()).toEqual({
expect(findIterationReportIssuesAt(0).props()).toMatchObject({
...defaultProps,
label: selectedLabels[0],
});
});
it('shows issues for `Tooling` label', () => {
expect(findIterationReportIssuesAt(1).props()).toEqual({
expect(findIterationReportIssuesAt(1).props()).toMatchObject({
...defaultProps,
label: selectedLabels[1],
});
......@@ -154,5 +184,94 @@ describe('Iterations report tabs', () => {
expect(findIterationReportIssuesAt(2).isVisible()).toBe(false);
});
});
describe('when labels with issues and no issues are selected', () => {
beforeEach(() => {
// User groups issues by 2 labels
findLabelsSelect().vm.$emit('updateSelectedLabels', selectedLabels);
// API call updates for the 2 labels are emitted
findIterationReportIssues().vm.$emit('issuesUpdate', {
count: 3,
labelId: selectedLabels[0].id,
});
findIterationReportIssues().vm.$emit('issuesUpdate', {
count: 0,
labelId: selectedLabels[1].id,
});
});
it('shows an alert to tell the user that labels have no issues', () => {
expect(findNoIssuesAlert().text()).toBe('Labels with no issues in this iteration:');
});
it('shows the label with no issue, `Tooling`, in the alert', () => {
expect(findNoIssuesAlert().findComponent(GlLabel).props()).toMatchObject({
backgroundColor: selectedLabels[1].color,
description: selectedLabels[1].description,
target: null,
title: selectedLabels[1].title,
});
});
it('does not show empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
it('shows 2 IterationReportIssues blocks (one for `Security`, and one for the hidden ungrouped list)', () => {
expect(findAllIterationReportIssues()).toHaveLength(2);
});
it('shows issues for `Security` label', () => {
expect(findIterationReportIssuesAt(0).props()).toMatchObject({
...defaultProps,
label: selectedLabels[0],
});
});
it('hides issues for the ungrouped issues list', () => {
expect(findIterationReportIssuesAt(1).isVisible()).toBe(false);
});
});
describe('when labels with no issues are selected', () => {
beforeEach(() => {
// User groups issues by 2 labels
findLabelsSelect().vm.$emit('updateSelectedLabels', selectedLabels);
// API call updates for the 2 labels are emitted
findIterationReportIssues().vm.$emit('issuesUpdate', {
count: 0,
labelId: selectedLabels[0].id,
});
findIterationReportIssues().vm.$emit('issuesUpdate', {
count: 0,
labelId: selectedLabels[1].id,
});
});
it('shows an alert to tell the user that labels have no issues', () => {
expect(findNoIssuesAlert().text()).toBe('Labels with no issues in this iteration:');
});
it('shows the labels with no issue, `Security` and `Tooling`, in the alert', () => {
const labels = findNoIssuesAlert().findAllComponents(GlLabel);
expect(labels.at(0).props('title')).toBe(selectedLabels[0].title);
expect(labels.at(1).props('title')).toBe(selectedLabels[1].title);
});
it('shows empty state', () => {
expect(findEmptyState().props('title')).toBe('No issues found for the selected labels');
});
it('shows 1 IterationReportIssues block (one for the hidden ungrouped list)', () => {
expect(findAllIterationReportIssues()).toHaveLength(1);
});
it('hides issues for the ungrouped issues list', () => {
expect(findIterationReportIssuesAt(0).isVisible()).toBe(false);
});
});
});
});
......@@ -11,7 +11,7 @@ RSpec.shared_examples 'iteration report group by label' do
end
it 'groups by label', :aggregate_failures do
expect(page).to have_button('Collapse')
expect(page).to have_button('Collapse issues')
expect(page).to have_css('.gl-label', text: label1.title)
expect(page).to have_css('.gl-badge', text: 2)
......@@ -24,7 +24,7 @@ RSpec.shared_examples 'iteration report group by label' do
it 'shows ungrouped issues when `Group by: None` is selected again', :aggregate_failures do
select 'None', from: 'Group by'
expect(page).to have_no_button('Collapse')
expect(page).to have_no_button('Collapse issues')
expect(page).to have_no_css('.gl-label', text: label1.title)
expect(page).to have_no_css('.gl-badge', text: 2)
......
......@@ -7291,6 +7291,9 @@ msgstr ""
msgid "Collapse approvers"
msgstr ""
msgid "Collapse issues"
msgstr ""
msgid "Collapse milestones"
msgstr ""
......@@ -12068,6 +12071,9 @@ msgstr ""
msgid "Expand file"
msgstr ""
msgid "Expand issues"
msgstr ""
msgid "Expand milestones"
msgstr ""
......@@ -16669,6 +16675,9 @@ msgstr ""
msgid "Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities"
msgstr ""
msgid "Issues with label %{label}"
msgstr ""
msgid "Issues with no epic assigned"
msgstr ""
......@@ -17248,6 +17257,9 @@ msgstr ""
msgid "Labels can be applied to issues and merge requests."
msgstr ""
msgid "Labels with no issues in this iteration:"
msgstr ""
msgid "Labels|%{spanStart}Promote label%{spanEnd} %{labelTitle} %{spanStart}to Group Label?%{spanEnd}"
msgstr ""
......@@ -20145,6 +20157,9 @@ msgstr ""
msgid "No issues found"
msgstr ""
msgid "No issues found for the selected labels"
msgstr ""
msgid "No iteration"
msgstr ""
......
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