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