Commit 919a8e68 authored by Coung Ngo's avatar Coung Ngo Committed by Vitaly Slobodin

Extract code from iteration_report_tabs.vue

Extract code to facilitate reuse of code in new component
iteration_report_issues.vue.

Additional code to `emit` event `issueCount` was added so the parent
component iteration_report_tabs.vue can still display the issue
count in its template.
parent 01eafdbc
......@@ -986,6 +986,8 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.labels-select-wrapper {
&.is-standalone {
min-width: $input-md-width;
.labels-select-dropdown-contents {
max-height: 350px;
......
......@@ -88,6 +88,22 @@ similar to how they appear when viewing a [milestone](../../project/milestones/i
Burndown charts help track completion progress of total scope, and burnup charts track the daily
total count and weight of issues added to and completed in a given timebox.
### Group issues by label
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/225500) in GitLab 13.8.
You can group the list of issues by label.
This can help you view issues that have your team's label,
and get a more accurate understanding of scope attributable to each label.
To group issues by label:
1. In the **Group by** dropdown, select **Label**.
1. Select the **Filter by label** dropdown.
1. Select the labels you want to group by in the labels dropdown.
You can also search for labels by typing in the search input.
1. Click or tap outside of the label dropdown. The page is now grouped by the selected labels.
## Disable iterations **(STARTER ONLY)**
GitLab Iterations feature is deployed with a feature flag that is **enabled by default**.
......
......@@ -88,6 +88,11 @@ export default {
required: false,
default: false,
},
labelsFetchPath: {
type: String,
required: false,
default: '',
},
namespaceType: {
type: String,
required: false,
......@@ -226,6 +231,7 @@ export default {
<iteration-report-tabs
:full-path="fullPath"
:iteration-id="iteration.id"
:labels-fetch-path="labelsFetchPath"
:namespace-type="namespaceType"
/>
</template>
......
<script>
import {
GlAlert,
GlAvatar,
GlBadge,
GlButton,
GlLabel,
GlLink,
GlLoadingIcon,
GlPagination,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { Namespace } from '../constants';
import iterationIssuesQuery from '../queries/iteration_issues.query.graphql';
import iterationIssuesWithLabelFilterQuery from '../queries/iteration_issues_with_label_filter.query.graphql';
const states = {
opened: 'opened',
closed: 'closed',
};
export default {
fields: [
{
key: 'title',
label: __('Title'),
class: 'gl-bg-transparent! gl-border-b-1',
},
{
key: 'status',
label: __('Status'),
class: 'gl-bg-transparent! gl-text-truncate',
thClass: 'gl-w-eighth',
},
{
key: 'assignees',
label: __('Assignees'),
class: 'gl-bg-transparent! gl-text-right',
thClass: 'gl-w-eighth',
},
],
components: {
GlAlert,
GlAvatar,
GlBadge,
GlButton,
GlLabel,
GlLink,
GlLoadingIcon,
GlPagination,
GlTable,
},
directives: {
GlTooltip: GlTooltipDirective,
},
apollo: {
issues: {
query() {
return this.label.title ? iterationIssuesWithLabelFilterQuery : iterationIssuesQuery;
},
variables() {
return this.queryVariables;
},
update(data) {
const { nodes: issues = [], count, pageInfo = {} } = data[this.namespaceType]?.issues || {};
const list = issues.map((issue) => ({
...issue,
labels: issue?.labels?.nodes || [],
assignees: issue?.assignees?.nodes || [],
}));
return {
pageInfo,
list,
count,
};
},
result({ data }) {
this.$emit('issueCount', data[this.namespaceType]?.issues?.count);
},
error() {
this.error = __('Error loading issues');
},
},
},
props: {
fullPath: {
type: String,
required: true,
},
iterationId: {
type: String,
required: true,
},
label: {
type: Object,
required: false,
default: () => ({}),
},
namespaceType: {
type: String,
required: false,
default: Namespace.Group,
validator: (value) => Object.values(Namespace).includes(value),
},
},
data() {
return {
issues: {
list: [],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
},
},
error: '',
pagination: {
currentPage: 1,
},
isExpanded: true,
};
},
computed: {
accordionIcon() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
},
accordionName() {
return this.isExpanded ? __('Collapse') : __('Expand');
},
pageSize() {
const labelGroupingPageSize = 5;
const normalPageSize = 20;
return this.label.title ? labelGroupingPageSize : normalPageSize;
},
shouldShowTable() {
return this.isExpanded && !this.$apollo.queries.issues.loading;
},
queryVariables() {
const vars = {
fullPath: this.fullPath,
id: getIdFromGraphQLId(this.iterationId),
isGroup: this.namespaceType === Namespace.Group,
labelName: this.label.title,
};
if (this.pagination.beforeCursor) {
vars.beforeCursor = this.pagination.beforeCursor;
vars.lastPageSize = this.pageSize;
} else {
vars.afterCursor = this.pagination.afterCursor;
vars.firstPageSize = this.pageSize;
}
return vars;
},
prevPage() {
return Number(this.issues.pageInfo.hasPreviousPage);
},
nextPage() {
return Number(this.issues.pageInfo.hasNextPage);
},
},
methods: {
tooltipText(assignee) {
return sprintf(__('Assigned to %{assigneeName}'), {
assigneeName: assignee.name,
});
},
issueState(state, assigneeCount) {
if (state === states.opened && assigneeCount === 0) {
return __('Open');
}
if (state === states.opened && assigneeCount > 0) {
return __('In progress');
}
return __('Closed');
},
handlePageChange(page) {
const { startCursor, endCursor } = this.issues.pageInfo;
if (page > this.pagination.currentPage) {
this.pagination = {
afterCursor: endCursor,
currentPage: page,
};
} else {
this.pagination = {
beforeCursor: startCursor,
currentPage: page,
};
}
},
toggleIsExpanded() {
this.isExpanded = !this.isExpanded;
},
},
};
</script>
<template>
<div>
<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">
<gl-button
category="tertiary"
:icon="accordionIcon"
:aria-label="accordionName"
@click="toggleIsExpanded"
/>
<gl-label
class="gl-ml-1"
:background-color="label.color"
:description="label.description"
:scoped="label.scoped"
:title="label.title"
/>
<gl-badge class="gl-ml-2" size="sm" variant="muted">
{{ issues.count }}
</gl-badge>
</div>
<gl-loading-icon v-show="$apollo.queries.issues.loading" class="gl-my-9" size="md" />
<gl-table
v-show="shouldShowTable"
:items="issues.list"
:fields="$options.fields"
:empty-text="__('No issues found')"
:show-empty="true"
fixed
stacked="sm"
data-qa-selector="iteration_issues_container"
>
<template #cell(title)="{ item: { iid, title, webUrl } }">
<div class="gl-text-truncate">
<gl-link
class="gl-text-gray-900 gl-font-weight-bold"
:href="webUrl"
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>
</template>
<template #cell(status)="{ item: { state, assignees = [] } }">
<span class="gl-w-6 gl-flex-shrink-0">{{ issueState(state, assignees.length) }}</span>
</template>
<template #cell(assignees)="{ item: { assignees } }">
<span class="assignee-icon gl-w-6">
<span
v-for="assignee in assignees"
:key="assignee.username"
v-gl-tooltip="tooltipText(assignee)"
>
<gl-avatar :src="assignee.avatarUrl" :size="16" />
</span>
</span>
</template>
</gl-table>
<div v-show="isExpanded" class="gl-mt-3">
<gl-pagination
:value="pagination.currentPage"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
</div>
</div>
</template>
<script>
import {
GlAlert,
GlAvatar,
GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
GlTab,
GlTabs,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import query from '../queries/iteration_issues.query.graphql';
import { Namespace } from '../constants';
const states = {
opened: 'opened',
closed: 'closed',
};
const pageSize = 20;
import { GlBadge, GlFormSelect, GlTab, GlTabs } from '@gitlab/ui';
import { differenceBy, unionBy } from 'lodash';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import { GroupBy, Namespace } from '../constants';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import IterationReportIssues from './iteration_report_issues.vue';
import { __ } from '~/locale';
export default {
fields: [
selectOptions: [
{
key: 'title',
label: __('Title'),
class: 'gl-bg-transparent! gl-border-b-1',
value: GroupBy.None,
text: __('None'),
},
{
key: 'status',
label: __('Status'),
class: 'gl-bg-transparent! gl-text-truncate',
thClass: 'gl-w-eighth',
},
{
key: 'assignees',
label: __('Assignees'),
class: 'gl-bg-transparent! gl-text-right',
thClass: 'gl-w-eighth',
value: GroupBy.Label,
text: __('Label'),
},
],
variant: DropdownVariant.Standalone,
components: {
GlAlert,
GlAvatar,
GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
GlFormSelect,
GlTab,
GlTabs,
GlTable,
},
directives: {
GlTooltip: GlTooltipDirective,
},
apollo: {
issues: {
query,
variables() {
return this.queryVariables;
},
update(data) {
const { nodes: issues = [], count, pageInfo = {} } = data[this.namespaceType]?.issues || {};
const list = issues.map((issue) => ({
...issue,
labels: issue?.labels?.nodes || [],
assignees: issue?.assignees?.nodes || [],
}));
return {
pageInfo,
list,
count,
};
},
error() {
this.error = __('Error loading issues');
},
},
IterationReportIssues,
LabelsSelect,
},
props: {
fullPath: {
......@@ -92,6 +36,11 @@ export default {
type: String,
required: true,
},
labelsFetchPath: {
type: String,
required: false,
default: '',
},
namespaceType: {
type: String,
required: false,
......@@ -101,73 +50,35 @@ export default {
},
data() {
return {
issues: {
list: [],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
},
},
error: '',
pagination: {
currentPage: 1,
},
issueCount: undefined,
groupBySelection: GroupBy.None,
selectedLabels: [],
};
},
computed: {
queryVariables() {
const vars = {
fullPath: this.fullPath,
id: getIdFromGraphQLId(this.iterationId),
isGroup: this.namespaceType === Namespace.Group,
};
if (this.pagination.beforeCursor) {
vars.beforeCursor = this.pagination.beforeCursor;
vars.lastPageSize = pageSize;
} else {
vars.afterCursor = this.pagination.afterCursor;
vars.firstPageSize = pageSize;
}
return vars;
},
prevPage() {
return Number(this.issues.pageInfo.hasPreviousPage);
},
nextPage() {
return Number(this.issues.pageInfo.hasNextPage);
shouldShowFilterByLabel() {
return this.groupBySelection === GroupBy.Label;
},
},
methods: {
tooltipText(assignee) {
return sprintf(__('Assigned to %{assigneeName}'), {
assigneeName: assignee.name,
});
handleIssueCount(count) {
this.issueCount = count;
},
issueState(state, assigneeCount) {
if (state === states.opened && assigneeCount === 0) {
return __('Open');
handleSelectChange() {
if (this.groupBySelection === GroupBy.None) {
this.selectedLabels = [];
}
if (state === states.opened && assigneeCount > 0) {
return __('In progress');
}
return __('Closed');
},
handlePageChange(page) {
const { startCursor, endCursor } = this.issues.pageInfo;
handleUpdateSelectedLabels(labels) {
const labelsToAdd = labels.filter((label) => label.set);
const labelsToRemove = labels.filter((label) => !label.set);
const idProperty = 'id';
if (page > this.pagination.currentPage) {
this.pagination = {
afterCursor: endCursor,
currentPage: page,
};
} else {
this.pagination = {
beforeCursor: startCursor,
currentPage: page,
};
}
this.selectedLabels = unionBy(
differenceBy(this.selectedLabels, labelsToRemove, idProperty),
labelsToAdd,
idProperty,
);
},
},
};
......@@ -175,67 +86,60 @@ export default {
<template>
<gl-tabs>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">
{{ error }}
</gl-alert>
<gl-tab title="Issues">
<template #title>
<span>{{ __('Issues') }}</span
><gl-badge class="ml-2" variant="neutral">{{ issues.count }}</gl-badge>
><gl-badge class="gl-ml-2" variant="neutral">{{ issueCount }}</gl-badge>
</template>
<gl-loading-icon v-if="$apollo.queries.issues.loading" class="gl-my-9" size="md" />
<gl-table
v-else
:items="issues.list"
:fields="$options.fields"
:empty-text="__('No issues found')"
:show-empty="true"
fixed
stacked="sm"
data-qa-selector="iteration_issues_container"
>
<template #cell(title)="{ item: { iid, title, webUrl } }">
<div class="gl-text-truncate">
<gl-link
class="gl-text-gray-900 gl-font-weight-bold"
:href="webUrl"
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>
</template>
<template #cell(status)="{ item: { state, assignees = [] } }">
<span class="gl-w-6 gl-flex-shrink-0">{{ issueState(state, assignees.length) }}</span>
</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">
<label for="iteration-group-by">{{ __('Group by') }}</label>
<gl-form-select
id="iteration-group-by"
v-model="groupBySelection"
class="gl-w-auto"
:options="$options.selectOptions"
@change="handleSelectChange"
/>
</div>
<template #cell(assignees)="{ item: { assignees } }">
<span class="assignee-icon gl-w-6">
<span
v-for="assignee in assignees"
:key="assignee.username"
v-gl-tooltip="tooltipText(assignee)"
>
<gl-avatar :src="assignee.avatarUrl" :size="16" />
</span>
</span>
</template>
</gl-table>
<div class="mt-3">
<gl-pagination
:value="pagination.currentPage"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
<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"
>
<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"
:labels-fetch-path="labelsFetchPath"
:selected-labels="selectedLabels"
:variant="$options.variant"
@updateSelectedLabels="handleUpdateSelectedLabels"
/>
</div>
</div>
<iteration-report-issues
v-for="label in selectedLabels"
:key="label.id"
class="gl-mb-6"
:full-path="fullPath"
:iteration-id="iterationId"
:label="label"
:namespace-type="namespaceType"
:data-testid="`iteration-label-group-${label.id}`"
/>
<iteration-report-issues
v-show="!selectedLabels.length"
:full-path="fullPath"
:iteration-id="iterationId"
:namespace-type="namespaceType"
@issueCount="handleIssueCount"
/>
</gl-tab>
</gl-tabs>
</template>
......@@ -3,6 +3,11 @@ export const Namespace = {
Project: 'project',
};
export const GroupBy = {
None: 'none',
Label: 'label',
};
export const iterationStates = {
closed: 'closed',
upcoming: 'upcoming',
......
......@@ -61,6 +61,7 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) {
fullPath,
iterationId,
iterationIid,
labelsFetchPath,
editIterationPath,
previewMarkdownPath,
} = el.dataset;
......@@ -75,6 +76,7 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) {
fullPath,
iterationId,
iterationIid,
labelsFetchPath,
canEdit,
editIterationPath,
namespaceType,
......
#import "./iteration_issues.fragment.graphql"
query IterationIssuesWithLabelFilter(
$fullPath: ID!
$id: ID!
$isGroup: Boolean = true
$labelName: [String]
$beforeCursor: String = ""
$afterCursor: String = ""
$firstPageSize: Int
$lastPageSize: Int
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
issues(
iterationId: [$id]
labelName: $labelName
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
includeSubgroups: true
) {
...IterationIssues
}
}
project(fullPath: $fullPath) @skip(if: $isGroup) {
issues(
iterationId: [$id]
labelName: $labelName
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
...IterationIssues
}
}
}
......@@ -6,4 +6,5 @@
.js-iteration{ data: { full_path: @group.full_path,
can_edit: can?(current_user, :admin_iteration, @group).to_s,
iteration_iid: params[:id],
labels_fetch_path: group_labels_path(@group, format: :json, include_ancestor_groups: true),
preview_markdown_path: preview_markdown_path(@group) } }
......@@ -5,4 +5,5 @@
.js-iteration{ data: { full_path: @project.full_path,
can_edit: can?(current_user, :admin_iteration, @project).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) } }
---
title: Add ability to group issues by label within the iteration report
merge_request: 51113
author:
type: added
......@@ -13,11 +13,12 @@ RSpec.describe 'User views iteration' do
let_it_be(:iteration) { create(:iteration, :skip_future_date_validation, iid: 1, id: 2, group: group, title: 'Correct Iteration', description: 'iteration description', start_date: now - 1.day, due_date: now) }
let_it_be(:other_iteration) { create(:iteration, :skip_future_date_validation, iid: 2, id: 1, group: group, title: 'Wrong Iteration', start_date: now - 4.days, due_date: now - 3.days) }
let_it_be(:sub_group_iteration) { create(:iteration, id: 3, group: sub_group) }
let_it_be(:issue) { create(:issue, project: project, iteration: iteration) }
let_it_be(:assigned_issue) { create(:issue, project: project, iteration: iteration, assignees: [user]) }
let_it_be(:label1) { create(:label, project: project) }
let_it_be(:issue) { create(:issue, project: project, iteration: iteration, labels: [label1]) }
let_it_be(:assigned_issue) { create(:issue, project: project, iteration: iteration, assignees: [user], labels: [label1]) }
let_it_be(:closed_issue) { create(:closed_issue, project: project, iteration: iteration) }
let_it_be(:sub_group_issue) { create(:issue, project: sub_project, iteration: iteration) }
let_it_be(:other_issue) { create(:issue, project: project, iteration: other_iteration) }
let_it_be(:other_iteration_issue) { create(:issue, project: project, iteration: other_iteration) }
context 'with license', :js do
before do
......@@ -55,7 +56,7 @@ RSpec.describe 'User views iteration' do
expect(page).to have_content(assigned_issue.title)
expect(page).to have_content(closed_issue.title)
expect(page).to have_content(sub_group_issue.title)
expect(page).not_to have_content(other_issue.title)
expect(page).not_to have_content(other_iteration_issue.title)
end
if shows_actions
......@@ -79,6 +80,16 @@ RSpec.describe 'User views iteration' do
let(:shows_actions) { false }
end
end
context 'when grouping by label' do
before do
sign_in(user)
visit group_iteration_path(iteration.group, iteration.iid)
end
it_behaves_like 'iteration report group by label'
end
end
context 'without license' do
......
......@@ -10,22 +10,21 @@ RSpec.describe 'User views iteration' do
let_it_be(:user) { create(:group_member, :maintainer, user: create(:user), group: group).user }
let_it_be(:iteration) { create(:iteration, :skip_future_date_validation, iid: 1, id: 2, group: group, title: 'Correct Iteration', start_date: now - 1.day, due_date: now) }
let_it_be(:other_iteration) { create(:iteration, :skip_future_date_validation, iid: 2, id: 1, group: group, title: 'Wrong Iteration', start_date: now - 4.days, due_date: now - 3.days) }
let_it_be(:issue) { create(:issue, project: project, iteration: iteration) }
let_it_be(:assigned_issue) { create(:issue, project: project_2, iteration: iteration, assignees: [user]) }
let_it_be(:label1) { create(:label, project: project) }
let_it_be(:issue) { create(:issue, project: project, iteration: iteration, labels: [label1]) }
let_it_be(:assigned_issue) { create(:issue, project: project, iteration: iteration, assignees: [user], labels: [label1]) }
let_it_be(:closed_issue) { create(:closed_issue, project: project, iteration: iteration) }
let_it_be(:other_issue) { create(:issue, project: project, iteration: other_iteration) }
let_it_be(:other_iteration_issue) { create(:issue, project: project, iteration: other_iteration) }
let_it_be(:other_project_issue) { create(:issue, project: project_2, iteration: iteration, assignees: [user], labels: [label1]) }
context 'with license' do
context 'with license', :js do
before do
stub_licensed_features(iterations: true)
sign_in(user)
visit project_iterations_inherited_path(project, iteration.id)
end
context 'view an iteration', :js do
before do
visit project_iterations_inherited_path(project, iteration.id)
end
context 'view an iteration' do
it 'shows iteration info' do
aggregate_failures 'shows iteration info and dates' do
expect(page).to have_content(iteration.title)
......@@ -46,9 +45,10 @@ RSpec.describe 'User views iteration' do
aggregate_failures 'shows only issues that are part of the project' do
expect(page).to have_content(issue.title)
expect(page).not_to have_content(assigned_issue.title)
expect(page).to have_content(assigned_issue.title)
expect(page).to have_content(closed_issue.title)
expect(page).not_to have_content(other_issue.title)
expect(page).to have_no_content(other_project_issue.title)
expect(page).to have_no_content(other_iteration_issue.title)
end
aggregate_failures 'hides action dropdown for editing the iteration' do
......@@ -56,6 +56,10 @@ RSpec.describe 'User views iteration' do
end
end
end
context 'when grouping by label' do
it_behaves_like 'iteration report group by label'
end
end
context 'without license' do
......
import {
GlAlert,
GlAvatar,
GlBadge,
GlButton,
GlLabel,
GlLoadingIcon,
GlPagination,
GlTable,
} from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import IterationReportIssues from 'ee/iterations/components/iteration_report_issues.vue';
import { Namespace } from 'ee/iterations/constants';
describe('Iterations report issues', () => {
let wrapper;
const id = 3;
const fullPath = 'gitlab-org';
const label = {
id: 17,
title: 'Bug',
color: '#123456',
description: 'Bug label description',
scoped: false,
};
const defaultProps = {
fullPath,
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 mountComponent = ({
props = defaultProps,
loading = false,
data = {},
mountFunction = shallowMount,
} = {}) => {
wrapper = mountFunction(IterationReportIssues, {
propsData: props,
data() {
return data;
},
mocks: {
$apollo: {
queries: { issues: { loading } },
},
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('shows spinner while loading', () => {
mountComponent({
loading: true,
});
expect(findGlLoadingIcon().exists()).toBe(true);
expect(findGlTable().isVisible()).toBe(false);
});
it('shows iterations list when not loading', () => {
mountComponent({ loading: false, mountFunction: mount });
expect(findGlLoadingIcon().isVisible()).toBe(false);
expect(findGlTable().exists()).toBe(true);
expect(wrapper.text()).toContain('No issues found');
});
it('shows error in a gl-alert', () => {
const error = 'Oh no!';
mountComponent({
data: {
error,
},
});
expect(wrapper.find(GlAlert).text()).toContain(error);
});
describe('with issues', () => {
const pageSize = 20;
const totalIssues = pageSize + 1;
const assignees = Array(totalIssues)
.fill(null)
.map((_, i) => ({
id: i,
name: `User ${i}`,
username: `user${i}`,
state: 'active',
avatarUrl: 'http://invalid/avatar.png',
webUrl: `https://localhost:3000/user${i}`,
}));
const issues = Array(totalIssues)
.fill(null)
.map((_, i) => ({
id: i,
title: `Issue ${i}`,
assignees: assignees.slice(0, i),
}));
const findIssues = () => wrapper.findAll('table tbody tr');
const findAssigneesForIssue = (index) => findIssues().at(index).findAll(GlAvatar);
describe('issue_list', () => {
beforeEach(() => {
const data = {
issues: {
list: issues,
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'first-item',
endCursor: 'last-item',
},
count: issues.length,
},
};
mountComponent({ data, mountFunction: mount });
});
it('shows issue list in table', () => {
expect(findGlTable().exists()).toBe(true);
expect(findIssues()).toHaveLength(issues.length);
});
it('shows assignees', () => {
expect(findAssigneesForIssue(0)).toHaveLength(0);
expect(findAssigneesForIssue(1)).toHaveLength(1);
expect(findAssigneesForIssue(10)).toHaveLength(10);
});
});
describe('pagination', () => {
beforeEach(() => {
const data = {
issues: {
list: issues,
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'first-item',
endCursor: 'last-item',
},
count: issues.length,
},
};
mountComponent({ data });
});
const findPagination = () => wrapper.find(GlPagination);
const setPage = (page) => {
findPagination().vm.$emit('input', page);
return findPagination().vm.$nextTick();
};
it('passes prev, next, and current page props', () => {
expect(findPagination().exists()).toBe(true);
expect(findPagination().props()).toEqual(
expect.objectContaining({
value: wrapper.vm.pagination.currentPage,
prevPage: wrapper.vm.prevPage,
nextPage: wrapper.vm.nextPage,
}),
);
});
it('updates query variables when going to previous page', () => {
return setPage(1).then(() => {
expect(wrapper.vm.queryVariables).toEqual({
beforeCursor: 'first-item',
fullPath,
id,
lastPageSize: 20,
isGroup: true,
});
});
});
it('updates query variables when going to next page', () => {
return setPage(2).then(() => {
expect(wrapper.vm.queryVariables).toEqual({
afterCursor: 'last-item',
fullPath,
id,
firstPageSize: 20,
isGroup: true,
});
});
});
});
});
describe('IterationReportTabs query variables', () => {
const expected = {
afterCursor: undefined,
firstPageSize: 20,
fullPath: defaultProps.fullPath,
id,
};
describe('when group', () => {
it('has expected query variable values', () => {
mountComponent({
props: {
...defaultProps,
namespaceType: Namespace.Group,
},
});
expect(wrapper.vm.queryVariables).toEqual({
...expected,
isGroup: true,
});
});
});
describe('when project', () => {
it('has expected query variable values', () => {
mountComponent({
props: {
...defaultProps,
namespaceType: Namespace.Project,
},
});
expect(wrapper.vm.queryVariables).toEqual({
...expected,
isGroup: false,
});
});
});
});
describe('label grouping header', () => {
describe('when a label is provided', () => {
const count = 4;
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('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 badge with issue count', () => {
expect(findGlBadge().text()).toBe(count.toString());
});
});
describe('when a label is not provided', () => {
beforeEach(() => {
mountComponent();
});
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);
});
});
});
describe('expand/collapse behaviour', () => {
describe('when expanded', () => {
beforeEach(() => {
mountComponent({
props: { ...defaultProps, label },
data: { isExpanded: true },
});
});
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(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(findGlTable().isVisible()).toBe(false);
expect(findGlPagination().isVisible()).toBe(false);
});
});
describe('when collapsed', () => {
beforeEach(() => {
mountComponent({
props: { ...defaultProps, label },
data: { isExpanded: false },
});
});
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(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(findGlTable().isVisible()).toBe(true);
expect(findGlPagination().isVisible()).toBe(true);
});
});
});
});
......@@ -10,6 +10,7 @@ describe('Iterations report', () => {
const defaultProps = {
fullPath: 'gitlab-org',
iterationIid: '3',
labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true',
};
const findTopbar = () => wrapper.find({ ref: 'topbar' });
......@@ -111,6 +112,7 @@ describe('Iterations report', () => {
expect(iterationReportTabs.props()).toEqual({
fullPath: defaultProps.fullPath,
iterationId: iteration.id,
labelsFetchPath: defaultProps.labelsFetchPath,
namespaceType: Namespace.Group,
});
});
......
import { GlAlert, GlAvatar, GlLoadingIcon, GlPagination, GlTable, GlTab } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { GlBadge, GlFormSelect } from '@gitlab/ui';
import { getByText } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import IterationReportIssues from 'ee/iterations/components/iteration_report_issues.vue';
import IterationReportTabs from 'ee/iterations/components/iteration_report_tabs.vue';
import { Namespace } from 'ee/iterations/constants';
import { GroupBy, Namespace } from 'ee/iterations/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
describe('Iterations report tabs', () => {
let wrapper;
......@@ -10,10 +14,21 @@ describe('Iterations report tabs', () => {
const defaultProps = {
fullPath,
iterationId: `gid://gitlab/Iteration/${id}`,
namespaceType: Namespace.Group,
};
const mountComponent = ({ props = defaultProps, loading = false, data = {} } = {}) => {
wrapper = mount(IterationReportTabs, {
const findGlFormSelectOptionAt = (index) =>
wrapper.find(GlFormSelect).findAll('option').at(index);
const findIterationReportIssuesAt = (index) => wrapper.findAll(IterationReportIssues).at(index);
const findLabelsSelect = () => wrapper.find(LabelsSelect);
const mountComponent = ({
props = defaultProps,
loading = false,
data = {},
mountFunction = shallowMount,
} = {}) => {
wrapper = mountFunction(IterationReportTabs, {
propsData: props,
data() {
return data;
......@@ -23,11 +38,6 @@ describe('Iterations report tabs', () => {
queries: { issues: { loading } },
},
},
stubs: {
GlAvatar,
GlTab,
GlTable,
},
});
};
......@@ -36,173 +46,113 @@ describe('Iterations report tabs', () => {
wrapper = null;
});
it('shows spinner while loading', () => {
mountComponent({
loading: true,
describe('IterationReportIssues component', () => {
it('is rendered', () => {
mountComponent();
expect(wrapper.find(IterationReportIssues).isVisible()).toBe(true);
});
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.find(GlTable).exists()).toBe(false);
});
it('updates the issue count when issueCount is emitted', async () => {
mountComponent({ mountFunction: mount });
it('shows iterations list when not loading', () => {
mountComponent({
loading: false,
});
const issueCount = 7;
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find(GlTable).exists()).toBe(true);
expect(wrapper.text()).toContain('No issues found');
});
wrapper.find(IterationReportIssues).vm.$emit('issueCount', issueCount);
it('shows error in a gl-alert', () => {
const error = 'Oh no!';
await nextTick();
mountComponent({
data: {
error,
},
expect(wrapper.find(GlBadge).text()).toBe(issueCount.toString());
});
expect(wrapper.find(GlAlert).text()).toContain(error);
});
describe('with issues', () => {
const pageSize = 20;
const totalIssues = pageSize + 1;
const assignees = Array(totalIssues)
.fill(null)
.map((_, i) => ({
id: i,
name: `User ${i}`,
username: `user${i}`,
state: 'active',
avatarUrl: 'http://invalid/avatar.png',
webUrl: `https://localhost:3000/user${i}`,
}));
const issues = Array(totalIssues)
.fill(null)
.map((_, i) => ({
id: i,
title: `Issue ${i}`,
assignees: assignees.slice(0, i),
}));
const findIssues = () => wrapper.findAll('table tbody tr');
const findAssigneesForIssue = (index) => findIssues().at(index).findAll(GlAvatar);
describe('group by section', () => {
describe('select dropdown', () => {
beforeEach(() => {
mountComponent({ mountFunction: mount });
});
beforeEach(() => {
mountComponent();
it('shows label', () => {
expect(getByText(wrapper.element, 'Group by')).not.toBeNull();
});
wrapper.setData({
issues: {
list: issues,
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'first-item',
endCursor: 'last-item',
},
count: issues.length,
},
it('has `None` option', () => {
expect(findGlFormSelectOptionAt(0).text()).toBe('None');
});
});
it('shows issue list in table', () => {
expect(wrapper.find(GlTable).exists()).toBe(true);
expect(findIssues()).toHaveLength(issues.length);
it('has `Label` option', () => {
expect(findGlFormSelectOptionAt(1).text()).toBe('Label');
});
});
it('shows assignees', () => {
expect(findAssigneesForIssue(0)).toHaveLength(0);
expect(findAssigneesForIssue(1)).toHaveLength(1);
expect(findAssigneesForIssue(10)).toHaveLength(10);
});
describe('label picker', () => {
describe('when group by `None` option is selected', () => {
beforeEach(() => {
mountComponent();
});
describe('pagination', () => {
const findPagination = () => wrapper.find(GlPagination);
const setPage = (page) => {
findPagination().vm.$emit('input', page);
return findPagination().vm.$nextTick();
};
it('passes prev, next, and current page props', () => {
expect(findPagination().exists()).toBe(true);
expect(findPagination().props()).toEqual(
expect.objectContaining({
value: wrapper.vm.pagination.currentPage,
prevPage: wrapper.vm.prevPage,
nextPage: wrapper.vm.nextPage,
}),
);
it('is not shown', () => {
expect(findLabelsSelect().exists()).toBe(false);
});
});
it('updates query variables when going to previous page', () => {
return setPage(1).then(() => {
expect(wrapper.vm.queryVariables).toEqual({
beforeCursor: 'first-item',
fullPath,
id,
lastPageSize: 20,
isGroup: true,
});
describe('when group by `Label` option is selected', () => {
beforeEach(() => {
mountComponent({ data: { groupBySelection: GroupBy.Label } });
});
});
it('updates query variables when going to next page', () => {
return setPage(2).then(() => {
expect(wrapper.vm.queryVariables).toEqual({
afterCursor: 'last-item',
fullPath,
id,
firstPageSize: 20,
isGroup: true,
});
it('is shown', () => {
expect(getByText(wrapper.element, 'Filter by label')).not.toBeNull();
expect(findLabelsSelect().exists()).toBe(true);
});
});
});
});
describe('IterationReportTabs query variables', () => {
const expected = {
afterCursor: undefined,
firstPageSize: 20,
fullPath: defaultProps.fullPath,
id,
};
describe('when group', () => {
it('has expected query variable values', () => {
mountComponent({
props: {
...defaultProps,
namespaceType: Namespace.Group,
},
});
describe('issues grouped by labels', () => {
beforeEach(() => {
mountComponent({ data: { groupBySelection: GroupBy.Label } });
});
expect(wrapper.vm.queryVariables).toEqual({
...expected,
isGroup: true,
});
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,
},
];
beforeEach(() => {
findLabelsSelect().vm.$emit('updateSelectedLabels', selectedLabels);
});
});
describe('when project', () => {
it('has expected query variable values', () => {
mountComponent({
props: {
...defaultProps,
namespaceType: Namespace.Project,
},
it('shows issues for `Security` label', () => {
expect(findIterationReportIssuesAt(0).props()).toEqual({
...defaultProps,
label: selectedLabels[0],
});
});
expect(wrapper.vm.queryVariables).toEqual({
...expected,
isGroup: false,
it('shows issues for `Tooling` label', () => {
expect(findIterationReportIssuesAt(1).props()).toEqual({
...defaultProps,
label: selectedLabels[1],
});
});
it('hides issues for the ungrouped issues list', () => {
expect(findIterationReportIssuesAt(2).isVisible()).toBe(false);
});
});
});
});
# frozen_string_literal: true
RSpec.shared_examples 'iteration report group by label' do
before do
select 'Label', from: 'Group by'
# Select label `label1` from the labels dropdown picker
click_button 'Label'
click_link label1.title
send_keys(:escape)
end
it 'groups by label', :aggregate_failures do
expect(page).to have_button('Collapse')
expect(page).to have_css('.gl-label', text: label1.title)
expect(page).to have_css('.gl-badge', text: 2)
expect(page).to have_content(issue.title)
expect(page).to have_content(assigned_issue.title)
expect(page).to have_no_content(closed_issue.title)
expect(page).to have_no_content(other_iteration_issue.title)
end
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_css('.gl-label', text: label1.title)
expect(page).to have_no_css('.gl-badge', text: 2)
expect(page).to have_content(issue.title)
expect(page).to have_content(assigned_issue.title)
expect(page).to have_content(closed_issue.title)
expect(page).to have_no_content(other_iteration_issue.title)
end
end
......@@ -6,7 +6,7 @@ module QA
module Group
module Iteration
class Show < QA::Page::Base
view 'ee/app/assets/javascripts/iterations/components/iteration_report_tabs.vue' do
view 'ee/app/assets/javascripts/iterations/components/iteration_report_issues.vue' do
element :iteration_issues_container, required: true
element :iteration_issue_link
end
......
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