Commit 5b5b24b2 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '233479-add-test-cases-list' into 'master'

Add Test Case list

See merge request gitlab-org/gitlab!41998
parents c9360e43 1c197805
fragment Label on Label {
id
title
description
color
textColor
}
......@@ -4,4 +4,5 @@ export default {
...recentSearchesStorageKeysCE,
epics: 'epics-recent-searches',
requirements: 'requirements-recent-searches',
test_cases: 'test-cases-recent-searches',
};
import initTestCaseList from 'ee/test_case_list/test_case_list_bundle';
document.addEventListener('DOMContentLoaded', () => {
initTestCaseList({
mountPointSelector: '#js-test-cases-list',
});
});
<script>
import { GlEmptyState, GlButton, GlSprintf, GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
import { TestCaseStates, FilterStateEmptyMessage } from '../constants';
export default {
components: {
GlEmptyState,
GlButton,
GlSprintf,
GlLink,
},
props: {
currentState: {
type: String,
required: true,
},
testCasesCount: {
type: Object,
required: true,
},
},
inject: ['canCreateTestCase', 'testCaseNewPath', 'emptyStatePath'],
computed: {
emptyStateTitle() {
return this.testCasesCount[TestCaseStates.All]
? FilterStateEmptyMessage[this.currentState]
: __(
'With test cases, you can define conditions for your project to meet in determining quality',
);
},
showDescription() {
return !this.testCasesCount[TestCaseStates.All];
},
},
};
</script>
<template>
<div class="test-cases-empty-state-container">
<gl-empty-state :svg-path="emptyStatePath" :title="emptyStateTitle">
<template v-if="showDescription" #description>
<gl-sprintf
:message="
__(
'You can group test cases using labels. To learn about the future direction of this feature, visit %{linkStart}Quality Management direction page%{linkEnd}.',
)
"
>
<template #link="{ content }">
<gl-link
href="https://about.gitlab.com/direction/plan/quality_management/"
target="_blank"
>{{ content }}</gl-link
>
</template>
</gl-sprintf>
</template>
<template v-if="canCreateTestCase && showDescription" #actions>
<gl-button :href="testCaseNewPath" category="primary" variant="success">{{
__('New test case')
}}</gl-button>
</template>
</gl-empty-state>
</div>
</template>
import { __ } from '~/locale';
export const TestCaseStates = {
Opened: 'opened',
Closed: 'closed', // Change this to `archived` once supported
All: 'all',
};
export const TestCaseTabs = [
{
id: 'state-opened',
name: TestCaseStates.Opened,
title: __('Open'),
titleTooltip: __('Filter by test cases that are currently opened.'),
},
{
id: 'state-archived',
name: TestCaseStates.Closed, // Change this to `Archived` once supported
title: __('Archived'),
titleTooltip: __('Filter by test cases that are currently archived.'),
},
{
id: 'state-all',
name: TestCaseStates.All,
title: __('All'),
titleTooltip: __('Show all test cases.'),
},
];
export const AvailableSortOptions = [
{
id: 1,
title: __('Created date'),
sortDirection: {
descending: 'created_desc',
ascending: 'created_asc',
},
},
{
id: 2,
title: __('Last updated'),
sortDirection: {
descending: 'updated_desc',
ascending: 'updated_asc',
},
},
];
export const FilterStateEmptyMessage = {
opened: __('There are no open test cases'),
closed: __('There are no archived test cases'),
};
export const DEFAULT_PAGE_SIZE = 5;
#import "~/graphql_shared/fragments/author.fragment.graphql"
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query projectIssues(
$projectPath: ID!
$types: [IssueType!]
$state: IssuableState
$labelName: [String]
$search: String = ""
$sortBy: IssueSort = created_desc
$firstPageSize: Int
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
) {
project(fullPath: $projectPath) {
name
issues(
types: $types
state: $state
labelName: $labelName
search: $search
sort: $sortBy
first: $firstPageSize
last: $lastPageSize
after: $nextPageCursor
before: $prevPageCursor
) {
nodes {
iid
title
description
createdAt
updatedAt
webUrl
author {
...Author
}
labels {
nodes {
...Label
}
}
}
pageInfo {
...PageInfo
}
}
}
}
query projectIssueCounts($projectPath: ID!, $types: [IssueType!]) {
project(fullPath: $projectPath) {
issueStatusCounts(types: $types) {
opened
closed
all
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import {
urlParamsToObject,
convertObjectPropsToCamelCase,
parseBoolean,
} from '~/lib/utils/common_utils';
import TestCaseListApp from './components/test_case_list_root.vue';
import { TestCaseStates } from './constants';
Vue.use(VueApollo);
const initTestCaseList = ({ mountPointSelector }) => {
const mountPointEl = document.querySelector(mountPointSelector);
if (!mountPointEl) {
return null;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const {
canCreateTestCase,
page = 1,
prev = '',
next = '',
initialState = TestCaseStates.Opened,
initialSortBy = 'created_desc',
} = mountPointEl.dataset;
const initialFilterParams = Object.assign(
convertObjectPropsToCamelCase(urlParamsToObject(window.location.search.substring(1)), {
dropKeys: ['scope', 'utf8', 'state', 'sort'], // These keys are unsupported/unnecessary
}),
);
return new Vue({
el: mountPointEl,
apolloProvider,
provide: {
...mountPointEl.dataset,
canCreateTestCase: parseBoolean(canCreateTestCase),
page: parseInt(page, 10),
prev,
next,
initialState,
initialSortBy,
},
render: createElement =>
createElement(TestCaseListApp, {
props: {
initialFilterParams,
},
}),
});
};
export default initTestCaseList;
- breadcrumb_title _('Test Cases')
- page_title _('Test Cases')
- @content_class = 'project-test-cases'
#js-test-cases-list{ data: { can_create_test_case: can?(current_user, :create_issue, @project).to_s,
initial_state: params[:state],
page: params[:page],
prev: params[:prev],
next: params[:next],
initial_sort_by: params[:sort],
project_full_path: @project.full_path,
project_labels_path: project_labels_path(@project, format: :json),
test_case_new_path: new_project_quality_test_case_path(@project),
empty_state_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg') } }
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Test Cases', :js do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:label) { create(:label, project: project, title: 'bug') }
let_it_be(:test_case1) { create(:quality_test_case, project: project, author: user, created_at: 5.days.ago, updated_at: 2.days.ago, labels: [label]) }
let_it_be(:test_case2) { create(:quality_test_case, project: project, author: user, created_at: 6.days.ago, updated_at: 2.days.ago) }
let_it_be(:test_case3) { create(:quality_test_case, project: project, author: user, created_at: 7.days.ago, updated_at: 2.days.ago) }
let_it_be(:test_case_archived) { create(:quality_test_case, project: project, author: user, created_at: 7.days.ago, updated_at: 2.days.ago, state: :closed) }
before do
project.add_developer(user)
stub_licensed_features(quality_management: true)
sign_in(user)
end
context 'test case list' do
before do
visit project_quality_test_cases_path(project)
wait_for_all_requests
end
it 'shows tabs for state types' do
page.within('.issuable-list-container .gl-tabs') do
tabs = page.find_all('li.nav-item')
expect(tabs[0]).to have_content('Open 3')
expect(tabs[1]).to have_content('Archived 1')
expect(tabs[2]).to have_content('All 4')
end
end
it 'shows create test case button' do
page.within('.issuable-list-container .nav-controls') do
new_test_case = page.find('a')
expect(new_test_case).to have_content('New test case')
expect(new_test_case[:href]).to have_content(new_project_quality_test_case_path(project))
end
end
it 'shows filtered search input' do
page.within('.issuable-list-container .vue-filtered-search-bar-container') do
expect(page).to have_selector('.gl-search-box-by-click')
expect(page.find('.gl-filtered-search-term-input')[:placeholder]).to eq('Search test cases')
expect(page).to have_selector('.sort-dropdown-container')
page.find('.sort-dropdown-container button.gl-dropdown-toggle').click
expect(page.find('.sort-dropdown-container')).to have_selector('li', count: 2)
end
end
context 'open tab' do
it 'shows list of all open test cases' do
page.within('.issuable-list-container .issuable-list') do
expect(page).to have_selector('li.issue', count: 3)
end
end
it 'shows test cases title and metadata' do
page.within('.issuable-list-container .issuable-list li.issue', match: :first) do
expect(page.find('.issue-title')).to have_content(test_case1.title)
expect(page.find('.issuable-reference')).to have_content("##{test_case1.iid}")
expect(page.find('.issuable-authored')).to have_content('created 5 days ago by')
expect(page.find('.author')).to have_content(user.name)
expect(page.find('div.issuable-updated-at')).to have_content('updated 2 days ago')
end
end
end
context 'archived tab' do
before do
find(:link, text: 'Archived').click
wait_for_requests
end
it 'shows list of all archived test cases' do
page.within('.issuable-list-container .issuable-list') do
expect(page).to have_selector('li.issue', count: 1)
end
end
end
context 'all tab' do
before do
find(:link, text: 'All').click
wait_for_requests
end
it 'shows list of all test cases' do
page.within('.issuable-list-container .issuable-list') do
expect(page).to have_selector('li.issue', count: 4)
end
end
end
end
end
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState, GlSprintf, GlButton } from '@gitlab/ui';
import TestCaseListEmptyState from 'ee/test_case_list/components/test_case_list_empty_state.vue';
const createComponent = (props = {}) =>
shallowMount(TestCaseListEmptyState, {
provide: {
canCreateTestCase: true,
testCaseNewPath: '/gitlab-org/gitlab-test/-/quality/test_cases/new',
emptyStatePath: '/assets/illustrations/empty-state/test-cases.svg',
},
propsData: {
currentState: 'opened',
testCasesCount: {
opened: 0,
closed: 0,
all: 0,
},
...props,
},
stubs: { GlEmptyState },
});
describe('TestCaseListEmptyState', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('emptyStateTitle', () => {
it('returns string "There are no open test cases" when value of `currentState` prop is "opened" and project has some test cases', async () => {
wrapper.setProps({
testCasesCount: {
opened: 0,
closed: 2,
all: 2,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.emptyStateTitle).toBe('There are no open test cases');
});
it('returns string "There are no archived test cases" when value of `currenState` prop is "closed" and project has some test cases', async () => {
wrapper.setProps({
currentState: 'closed',
testCasesCount: {
opened: 2,
closed: 0,
all: 2,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.emptyStateTitle).toBe('There are no archived test cases');
});
it('returns a generic string when project has no test cases', () => {
expect(wrapper.vm.emptyStateTitle).toBe(
'With test cases, you can define conditions for your project to meet in determining quality',
);
});
});
describe('showDescription', () => {
it.each`
allCount | returnValue
${0} | ${true}
${1} | ${false}
`(
'returns $returnValue when count of total test cases in project is $allCount',
async ({ allCount, returnValue }) => {
wrapper.setProps({
testCasesCount: {
opened: allCount,
closed: 0,
all: allCount,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.showDescription).toBe(returnValue);
},
);
});
});
describe('template', () => {
it('renders gl-empty-state component', () => {
expect(wrapper.find(GlEmptyState).exists()).toBe(true);
});
it('renders empty state description', () => {
const descriptionEl = wrapper.find(GlSprintf);
expect(descriptionEl.exists()).toBe(true);
expect(descriptionEl.attributes('message')).toBe(
'You can group test cases using labels. To learn about the future direction of this feature, visit %{linkStart}Quality Management direction page%{linkEnd}.',
);
});
it('renders "New test cases" button', () => {
const buttonEl = wrapper.find(GlButton);
expect(buttonEl.exists()).toBe(true);
expect(buttonEl.attributes('href')).toBe('/gitlab-org/gitlab-test/-/quality/test_cases/new');
expect(buttonEl.text()).toBe('New test case');
});
});
});
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import { mockIssuable } from 'jest/issuable_list/mock_data';
import TestCaseListRoot from 'ee/test_case_list/components/test_case_list_root.vue';
import { TestCaseTabs, AvailableSortOptions } from 'ee/test_case_list/constants';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
jest.mock('~/flash');
jest.mock('ee/test_case_list/constants', () => ({
DEFAULT_PAGE_SIZE: 2,
TestCaseTabs: jest.requireActual('ee/test_case_list/constants').TestCaseTabs,
AvailableSortOptions: jest.requireActual('ee/test_case_list/constants').AvailableSortOptions,
}));
const mockProvide = {
canCreateTestCase: true,
initialState: 'opened',
page: 1,
prev: '',
next: '',
initialSortBy: 'created_desc',
projectFullPath: 'gitlab-org/gitlab-test',
projectLabelsPath: '/gitlab-org/gitlab-test/-/labels.json',
testCaseNewPath: '/gitlab-org/gitlab-test/-/quality/test_cases/new',
};
const mockPageInfo = {
startCursor: 'eyJpZCI6IjI1IiwiY3JlYXRlZF9hdCI6IjIwMjAtMDMtMzEgMTM6MzI6MTQgVVRDIn0',
endCursor: 'eyJpZCI6IjIxIiwiY3JlYXRlZF9hdCI6IjIwMjAtMDMtMzEgMTM6MzE6MTUgVVRDIn0',
};
const createComponent = ({
provide = mockProvide,
initialFilterParams = {},
testCasesLoading = false,
testCasesList = [],
} = {}) =>
shallowMount(TestCaseListRoot, {
propsData: {
initialFilterParams,
},
provide,
mocks: {
$apollo: {
queries: {
testCases: {
loading: testCasesLoading,
list: testCasesList,
pageInfo: mockPageInfo,
},
testCasesCount: {
loading: testCasesLoading,
opened: 5,
closed: 0,
all: 5,
},
},
},
},
});
describe('TestCaseListRoot', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('testCaseListLoading', () => {
it.each`
testCasesLoading | returnValue
${true} | ${true}
${false} | ${false}
`(
'returns $returnValue when testCases query loading is $loadingValue',
({ testCasesLoading, returnValue }) => {
const wrapperTemp = createComponent({
provide: mockProvide,
initialFilterParams: {},
testCasesList: [],
testCasesLoading,
});
expect(wrapperTemp.vm.testCaseListLoading).toBe(returnValue);
wrapperTemp.destroy();
},
);
});
describe('testCaseListEmpty', () => {
it.each`
testCasesLoading | testCasesList | testCaseListDescription | returnValue
${true} | ${[]} | ${'empty'} | ${false}
${true} | ${[mockIssuable]} | ${'not empty'} | ${false}
${false} | ${[]} | ${'not empty'} | ${true}
${false} | ${[mockIssuable]} | ${'empty'} | ${true}
`(
'returns $returnValue when testCases query loading is $testCasesLoading and testCases array is $testCaseListDescription',
({ testCasesLoading, testCasesList, returnValue }) => {
const wrapperTemp = createComponent({
provide: mockProvide,
initialFilterParams: {},
testCasesLoading,
testCasesList,
});
expect(wrapperTemp.vm.testCaseListEmpty).toBe(returnValue);
wrapperTemp.destroy();
},
);
});
describe('showPaginationControls', () => {
it.each`
hasPreviousPage | hasNextPage | returnValue
${true} | ${undefined} | ${true}
${undefined} | ${true} | ${true}
${false} | ${undefined} | ${false}
${undefined} | ${false} | ${false}
${false} | ${false} | ${false}
${true} | ${true} | ${true}
`(
'returns $returnValue when hasPreviousPage is $hasPreviousPage and hasNextPage is $hasNextPage within `testCases.pageInfo`',
async ({ hasPreviousPage, hasNextPage, returnValue }) => {
wrapper.setData({
testCases: {
pageInfo: {
hasPreviousPage,
hasNextPage,
},
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.showPaginationControls).toBe(returnValue);
},
);
it.each`
testCasesList | testCaseListDescription | returnValue
${[]} | ${'empty'} | ${false}
${[mockIssuable]} | ${'not empty'} | ${true}
`(
'returns $returnValue when testCases array is $testCaseListDescription',
async ({ testCasesList, returnValue }) => {
wrapper.setData({
testCases: {
list: testCasesList,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.showPaginationControls).toBe(returnValue);
},
);
});
describe('previousPage', () => {
it('returns number representing previous page based on currentPage value', () => {
wrapper.setData({
currentPage: 3,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.previousPage).toBe(2);
});
});
});
describe('nextPage', () => {
beforeEach(() => {
wrapper.setData({
testCasesCount: {
opened: 5,
closed: 0,
all: 5,
},
});
});
it('returns number representing next page based on currentPage value', async () => {
wrapper.setData({
currentPage: 1,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.nextPage).toBe(2);
});
it('returns `null` when currentPage is already last page', async () => {
wrapper.setData({
currentPage: 3,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.nextPage).toBeNull();
});
});
});
describe('methods', () => {
describe('updateUrl', () => {
it('updates window URL based on presence of props for filtered search and sort criteria', async () => {
wrapper.setData({
currentState: 'opened',
currentPage: 2,
nextPageCursor: 'abc123',
sortedBy: 'updated_asc',
filterParams: {
authorUsernames: 'root',
search: 'foo',
labelName: ['bug'],
},
});
await wrapper.vm.$nextTick();
wrapper.vm.updateUrl();
expect(global.window.location.href).toBe(
`${TEST_HOST}/?state=opened&sort=updated_asc&page=2&next=abc123&label_name%5B%5D=bug&search=foo`,
);
});
});
});
describe('template', () => {
const getIssuableList = () => wrapper.find(IssuableList);
it('renders issuable-list component', () => {
expect(getIssuableList().exists()).toBe(true);
expect(getIssuableList().props()).toMatchObject({
namespace: mockProvide.projectFullPath,
tabs: TestCaseTabs,
tabCounts: {
opened: 0,
closed: 0,
all: 0,
},
currentTab: 'opened',
searchInputPlaceholder: 'Search test cases',
searchTokens: expect.any(Array),
sortOptions: AvailableSortOptions,
initialSortBy: 'created_desc',
issuables: [],
issuablesLoading: false,
showPaginationControls: wrapper.vm.showPaginationControls,
defaultPageSize: 2, // mocked value in tests
currentPage: 1,
previousPage: 0,
nextPage: null,
recentSearchesStorageKey: 'test_cases',
issuableSymbol: '#',
});
});
describe('issuable-list events', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateUrl').mockImplementation(jest.fn);
});
it('click-tab event changes currentState value and calls updateUrl', () => {
getIssuableList().vm.$emit('click-tab', 'closed');
expect(wrapper.vm.currentState).toBe('closed');
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
});
it('page-change event changes prevPageCursor and nextPageCursor values based on based on currentPage and calls updateUrl', () => {
wrapper.setData({
testCases: {
pageInfo: mockPageInfo,
},
});
getIssuableList().vm.$emit('page-change', 2);
expect(wrapper.vm.prevPageCursor).toBe('');
expect(wrapper.vm.nextPageCursor).toBe(mockPageInfo.endCursor);
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
});
it('filter event changes filterParams value and calls updateUrl', () => {
getIssuableList().vm.$emit('filter', [
{
type: 'author_username',
value: {
data: 'root',
},
},
{
type: 'label_name',
value: {
data: 'bug',
},
},
{
type: 'filtered-search-term',
value: {
data: 'foo',
},
},
]);
expect(wrapper.vm.filterParams).toEqual({
authorUsername: 'root',
labelName: ['bug'],
search: 'foo',
});
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
});
it('sort event changes sortedBy value and calls updateUrl', () => {
getIssuableList().vm.$emit('sort', 'updated_desc');
expect(wrapper.vm.sortedBy).toEqual('updated_desc');
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
});
});
});
});
......@@ -11090,6 +11090,12 @@ msgstr ""
msgid "Filter by status"
msgstr ""
msgid "Filter by test cases that are currently archived."
msgstr ""
msgid "Filter by test cases that are currently opened."
msgstr ""
msgid "Filter by two-factor authentication"
msgstr ""
......@@ -16954,6 +16960,9 @@ msgstr ""
msgid "New tag"
msgstr ""
msgid "New test case"
msgstr ""
msgid "New users set to external"
msgstr ""
......@@ -22185,6 +22194,9 @@ msgstr ""
msgid "Search results…"
msgstr ""
msgid "Search test cases"
msgstr ""
msgid "Search users"
msgstr ""
......@@ -23261,6 +23273,9 @@ msgstr ""
msgid "Show all requirements."
msgstr ""
msgid "Show all test cases."
msgstr ""
msgid "Show archived projects"
msgstr ""
......@@ -23670,6 +23685,9 @@ msgstr ""
msgid "Something went wrong while fetching comments. Please try again."
msgstr ""
msgid "Something went wrong while fetching count of test cases."
msgstr ""
msgid "Something went wrong while fetching description changes. Please try again."
msgstr ""
......@@ -23694,6 +23712,9 @@ msgstr ""
msgid "Something went wrong while fetching requirements list."
msgstr ""
msgid "Something went wrong while fetching test cases list."
msgstr ""
msgid "Something went wrong while fetching the environments for this merge request. Please try again."
msgstr ""
......@@ -25441,6 +25462,9 @@ msgstr ""
msgid "There are no archived requirements"
msgstr ""
msgid "There are no archived test cases"
msgstr ""
msgid "There are no changes"
msgstr ""
......@@ -25480,6 +25504,9 @@ msgstr ""
msgid "There are no open requirements"
msgstr ""
msgid "There are no open test cases"
msgstr ""
msgid "There are no packages yet"
msgstr ""
......@@ -28787,6 +28814,9 @@ msgstr ""
msgid "With requirements, you can set criteria to check your products against."
msgstr ""
msgid "With test cases, you can define conditions for your project to meet in determining quality"
msgstr ""
msgid "Withdraw Access Request"
msgstr ""
......@@ -28970,6 +29000,9 @@ msgstr ""
msgid "You can get started by cloning the repository or start adding files to it with one of the following options."
msgstr ""
msgid "You can group test cases using labels. To learn about the future direction of this feature, visit %{linkStart}Quality Management direction page%{linkEnd}."
msgstr ""
msgid "You can invite a new member to %{project_name} or invite another group."
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