Commit da977f43 authored by Phil Hughes's avatar Phil Hughes

Merge branch '233479-add-test-case-show' into 'master'

Add Test Case show

See merge request gitlab-org/gitlab!43522
parents fa79e06f cbc5129a
...@@ -30,6 +30,7 @@ const Api = { ...@@ -30,6 +30,7 @@ const Api = {
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
projectSearchPath: '/api/:version/projects/:id/search', projectSearchPath: '/api/:version/projects/:id/search',
projectMilestonesPath: '/api/:version/projects/:id/milestones', projectMilestonesPath: '/api/:version/projects/:id/milestones',
projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid',
mergeRequestsPath: '/api/:version/merge_requests', mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels', groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
...@@ -328,6 +329,14 @@ const Api = { ...@@ -328,6 +329,14 @@ const Api = {
}); });
}, },
addProjectIssueAsTodo(projectId, issueIid) {
const url = Api.buildUrl(Api.projectIssuePath)
.replace(':id', encodeURIComponent(projectId))
.replace(':issue_iid', encodeURIComponent(issueIid));
return axios.post(`${url}/todo`);
},
mergeRequests(params = {}) { mergeRequests(params = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath); const url = Api.buildUrl(Api.mergeRequestsPath);
......
export const IssuableType = {
Issue: 'issue',
Incident: 'incident',
TestCase: 'test_case',
};
...@@ -14,13 +14,20 @@ import { parseIssuableData } from '~/issue_show/utils/parse_data'; ...@@ -14,13 +14,20 @@ import { parseIssuableData } from '~/issue_show/utils/parse_data';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import { IssuableType } from '~/issuable_show/constants';
export default function() { export default function() {
const { issueType, ...issuableData } = parseIssuableData(); const { issueType, ...issuableData } = parseIssuableData();
if (issueType === 'incident') { switch (issueType) {
case IssuableType.Incident:
initIncidentApp(issuableData); initIncidentApp(issuableData);
} else if (issueType === 'issue') { break;
case IssuableType.Issue:
initIssueApp(issuableData); initIssueApp(issuableData);
break;
default:
break;
} }
initIssuableHeaderWarning(store); initIssuableHeaderWarning(store);
...@@ -31,12 +38,14 @@ export default function() { ...@@ -31,12 +38,14 @@ export default function() {
.then(module => module.default()) .then(module => module.default())
.catch(() => {}); .catch(() => {});
new ZenMode(); // eslint-disable-line no-new
if (issueType !== IssuableType.TestCase) {
new Issue(); // eslint-disable-line no-new new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
initIssuableSidebar(); initIssuableSidebar();
loadAwardsHandler(); loadAwardsHandler();
initInviteMemberModal(); initInviteMemberModal();
initInviteMemberTrigger(); initInviteMemberTrigger();
}
} }
import initSidebarBundle from 'ee/sidebar/sidebar_bundle'; import initSidebarBundle from 'ee/sidebar/sidebar_bundle';
import trackShowInviteMemberLink from 'ee/projects/track_invite_members'; import trackShowInviteMemberLink from 'ee/projects/track_invite_members';
import initTestCaseShow from 'ee/test_case_show/test_case_show_bundle';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
import initRelatedIssues from '~/related_issues'; import initRelatedIssues from '~/related_issues';
import initShow from '~/pages/projects/issues/show'; import initShow from '~/pages/projects/issues/show';
import UserCallout from '~/user_callout'; import UserCallout from '~/user_callout';
import { IssuableType } from '~/issuable_show/constants';
const { issueType } = parseIssuableData();
initShow(); initShow();
if (issueType === IssuableType.TestCase) {
initTestCaseShow({
mountPointSelector: '#js-issuable-app',
});
}
if (gon.features && !gon.features.vueIssuableSidebar) { if (gon.features && !gon.features.vueIssuableSidebar) {
initSidebarBundle(); initSidebarBundle();
} }
......
<script>
import { GlLoadingIcon, GlDropdown, GlDropdownDivider, GlDropdownItem, GlButton } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import IssuableShow from '~/issuable_show/components/issuable_show_root.vue';
import IssuableEventHub from '~/issuable_show/event_hub';
import TestCaseSidebar from './test_case_sidebar.vue';
import TestCaseGraphQL from '../mixins/test_case_graphql';
const stateEvent = {
Close: 'CLOSE',
Reopen: 'REOPEN',
};
export default {
components: {
GlLoadingIcon,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlButton,
IssuableShow,
TestCaseSidebar,
},
inject: [
'projectFullPath',
'testCaseNewPath',
'testCaseId',
'canEditTestCase',
'descriptionPreviewPath',
'descriptionHelpPath',
],
mixins: [TestCaseGraphQL],
data() {
return {
testCase: {},
editTestCaseFormVisible: false,
testCaseSaveInProgress: false,
testCaseStateChangeInProgress: false,
};
},
computed: {
isTestCaseOpen() {
return this.testCase.state === 'opened';
},
statusBadgeClass() {
return this.isTestCaseOpen ? 'status-box-open' : 'status-box-issue-closed';
},
statusIcon() {
return this.isTestCaseOpen ? 'issue-open-m' : 'mobile-issue-close';
},
statusBadgeText() {
return this.isTestCaseOpen ? __('Open') : __('Archived');
},
testCaseActionButtonVariant() {
return this.isTestCaseOpen ? 'warning' : 'default';
},
testCaseActionTitle() {
return this.isTestCaseOpen ? __('Archive test case') : __('Reopen test case');
},
todo() {
const todos = this.testCase.currentUserTodos.nodes;
return todos.length ? todos[0] : null;
},
selectedLabels() {
return this.testCase.labels.nodes.map(label => ({
...label,
id: getIdFromGraphQLId(label.id),
}));
},
},
methods: {
handleTestCaseStateChange() {
this.testCaseStateChangeInProgress = true;
return this.updateTestCase({
variables: {
stateEvent: this.isTestCaseOpen ? stateEvent.Close : stateEvent.Reopen,
},
errorMessage: s__('TestCases|Something went wrong while updating the test case.'),
})
.then(updatedTestCase => {
this.testCase = updatedTestCase;
})
.finally(() => {
this.testCaseStateChangeInProgress = false;
});
},
handleEditTestCase() {
this.editTestCaseFormVisible = true;
},
handleSaveTestCase({ issuableTitle, issuableDescription }) {
this.testCaseSaveInProgress = true;
return this.updateTestCase({
variables: {
title: issuableTitle,
description: issuableDescription,
},
errorMessage: s__('TestCases|Something went wrong while updating the test case.'),
})
.then(updatedTestCase => {
this.testCase = updatedTestCase;
this.editTestCaseFormVisible = false;
IssuableEventHub.$emit('update.issuable');
})
.finally(() => {
this.testCaseSaveInProgress = false;
});
},
handleCancelClick() {
this.editTestCaseFormVisible = false;
IssuableEventHub.$emit('close.form');
},
handleTestCaseUpdated(updatedTestCase) {
this.testCase = updatedTestCase;
},
},
};
</script>
<template>
<div class="test-case-container">
<gl-loading-icon v-if="testCaseLoading" size="md" class="gl-mt-3" />
<issuable-show
v-if="!testCaseLoading && !testCaseLoadFailed"
:issuable="testCase"
:status-badge-class="statusBadgeClass"
:status-icon="statusIcon"
:enable-edit="canEditTestCase"
:enable-autocomplete="true"
:edit-form-visible="editTestCaseFormVisible"
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
@edit-issuable="handleEditTestCase"
>
<template #status-badge>
{{ statusBadgeText }}
</template>
<template #header-actions>
<gl-dropdown
v-if="canEditTestCase"
data-testid="actions-dropdown"
:text="__('Options')"
:right="true"
class="d-md-none d-lg-none d-xl-none gl-flex-grow-1"
>
<gl-dropdown-item>{{ testCaseActionTitle }}</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-item :href="testCaseNewPath">{{ __('New test case') }}</gl-dropdown-item>
</gl-dropdown>
<gl-button
v-if="canEditTestCase"
data-testid="archive-test-case"
category="secondary"
class="d-none d-sm-none d-md-inline-block gl-mr-2"
:variant="testCaseActionButtonVariant"
:loading="testCaseStateChangeInProgress"
@click="handleTestCaseStateChange"
>{{ testCaseActionTitle }}</gl-button
>
<gl-button
data-testid="new-test-case"
category="secondary"
variant="success"
class="d-md-inline-block"
:class="{ 'd-none d-sm-none': canEditTestCase, 'gl-flex-grow-1': !canEditTestCase }"
:href="testCaseNewPath"
>{{ __('New test case') }}</gl-button
>
</template>
<template #edit-form-actions="issuableMeta">
<gl-button
data-testid="save-test-case"
:disable="testCaseSaveInProgress || !issuableMeta.issuableTitle.length"
:loading="testCaseSaveInProgress"
category="primary"
variant="success"
class="float-left qa-save-button"
@click.prevent="handleSaveTestCase(issuableMeta)"
>{{ __('Save changes') }}</gl-button
>
<gl-button
data-testid="cancel-test-case-edit"
class="float-right"
@click="handleCancelClick"
>
{{ __('Cancel') }}
</gl-button>
</template>
<template #right-sidebar-items="{ sidebarExpanded }">
<test-case-sidebar
:sidebar-expanded="sidebarExpanded"
:selected-labels="selectedLabels"
:todo="todo"
@test-case-updated="handleTestCaseUpdated"
/>
</template>
</issuable-show>
</div>
</template>
<script>
import { GlTooltipDirective as GlTooltip, GlButton, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import Mousetrap from 'mousetrap';
import { s__, __ } from '~/locale';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import TestCaseGraphQL from '../mixins/test_case_graphql';
export default {
components: {
GlButton,
GlIcon,
GlLoadingIcon,
LabelsSelect,
},
directives: {
GlTooltip,
},
inject: [
'projectFullPath',
'testCaseId',
'canEditTestCase',
'labelsFetchPath',
'labelsManagePath',
],
mixins: [TestCaseGraphQL],
props: {
sidebarExpanded: {
type: Boolean,
required: true,
},
todo: {
type: Object,
required: false,
default: null,
},
selectedLabels: {
type: Array,
required: true,
},
},
data() {
return {
sidebarExpandedOnClick: false,
testCaseLabelsSelectInProgress: false,
};
},
computed: {
isTodoPending() {
return this.todo?.state === 'pending';
},
todoUpdateInProgress() {
return this.$apollo.queries.testCase.loading || this.testCaseTodoUpdateInProgress;
},
todoActionText() {
return this.isTodoPending ? __('Mark as done') : __('Add a to do');
},
todoIcon() {
return this.isTodoPending ? 'todo-done' : 'todo-add';
},
},
mounted() {
Mousetrap.bind('l', this.handleLabelsCollapsedButtonClick);
},
beforeDestroy() {
Mousetrap.unbind('l');
},
methods: {
handleTodoButtonClick() {
if (this.isTodoPending) {
this.markTestCaseTodoDone();
} else {
this.addTestCaseAsTodo();
}
},
toggleSidebar() {
document.querySelector('.js-toggle-right-sidebar-button').dispatchEvent(new Event('click'));
},
handleLabelsDropdownClose() {
if (this.sidebarExpandedOnClick) {
this.sidebarExpandedOnClick = false;
this.toggleSidebar();
}
},
handleLabelsCollapsedButtonClick() {
// Expand the sidebar if not already expanded.
if (!this.sidebarExpanded) {
this.toggleSidebar();
this.sidebarExpandedOnClick = true;
}
// Wait for sidebar expand to complete before
// revealing labels dropdown.
this.$nextTick(() => {
document
.querySelector('.js-labels-block .js-sidebar-dropdown-toggle')
.dispatchEvent(new Event('click', { bubbles: true, cancelable: false }));
});
},
handleUpdateSelectedLabels(labels) {
// Iterate over selection and check if labels which were
// either selected or removed aren't leading to same selection
// as current one, as then we don't want to make network call
// since nothing has changed.
const anyLabelUpdated = labels.some(label => {
// Find this label in existing selection.
const existingLabel = this.selectedLabels.find(l => l.id === label.id);
// Check either of the two following conditions;
// 1. A label that's not currently applied is being applied.
// 2. A label that's already applied is being removed.
return (!existingLabel && label.set) || (existingLabel && !label.set);
});
// Only proceed with action if there are any label updates to be done.
if (anyLabelUpdated) {
this.testCaseLabelsSelectInProgress = true;
return this.updateTestCase({
variables: {
addLabelIds: labels.filter(label => label.set).map(label => label.id),
removeLabelIds: labels.filter(label => !label.set).map(label => label.id),
},
errorMessage: s__('TestCases|Something went wrong while updating the test case labels.'),
})
.then(updatedTestCase => {
this.$emit('test-case-updated', updatedTestCase);
})
.finally(() => {
this.testCaseLabelsSelectInProgress = false;
});
}
return null;
},
},
};
</script>
<template>
<div class="test-case-sidebar-items">
<template v-if="canEditTestCase">
<div v-if="sidebarExpanded" data-testid="todo" class="block todo gl-display-flex">
<span class="gl-flex-grow-1">{{ __('To Do') }}</span>
<gl-button :loading="todoUpdateInProgress" size="small" @click="handleTodoButtonClick">{{
todoActionText
}}</gl-button>
</div>
<div v-else class="block todo">
<button
v-gl-tooltip.viewport="{ placement: 'left' }"
:title="todoActionText"
class="btn-blank sidebar-collapsed-icon"
@click="handleTodoButtonClick"
>
<gl-loading-icon v-if="todoUpdateInProgress" />
<gl-icon v-else :name="todoIcon" :class="{ 'todo-undone': isTodoPending }" />
</button>
</div>
</template>
<labels-select
:allow-label-edit="canEditTestCase"
:allow-label-create="true"
:allow-multiselect="true"
:allow-scoped-labels="true"
:selected-labels="selectedLabels"
:labels-select-in-progress="testCaseLabelsSelectInProgress"
:labels-fetch-path="labelsFetchPath"
:labels-manage-path="labelsManagePath"
variant="sidebar"
class="block labels js-labels-block"
@updateSelectedLabels="handleUpdateSelectedLabels"
@onDropdownClose="handleLabelsDropdownClose"
@toggleCollapse="handleLabelsCollapsedButtonClick"
>{{ __('None') }}</labels-select
>
</div>
</template>
import Api from '~/api';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import projectTestCase from '../queries/project_test_case.query.graphql';
import updateTestCase from '../queries/update_test_case.mutation.graphql';
import markTestCaseTodoDone from '../queries/mark_test_case_todo_done.mutation.graphql';
export default {
apollo: {
testCase: {
query: projectTestCase,
variables() {
return {
projectPath: this.projectFullPath,
testCaseId: this.testCaseId,
};
},
update(data) {
return data.project?.issue;
},
result() {
this.testCaseLoading = false;
},
error(error) {
this.testCaseLoadFailed = true;
createFlash({
message: s__('TestCases|Something went wrong while fetching test case.'),
captureError: true,
error,
});
throw error;
},
},
},
data() {
return {
testCaseLoading: true,
testCaseLoadFailed: false,
testCaseTodoUpdateInProgress: false,
};
},
methods: {
updateTestCase({ variables, errorMessage }) {
return this.$apollo
.mutate({
mutation: updateTestCase,
variables: {
updateTestCaseInput: {
projectPath: this.projectFullPath,
iid: this.testCaseId,
...variables,
},
},
})
.then(({ data = {} }) => {
const errors = data.updateIssue?.errors;
if (errors?.length) {
throw new Error(`Error updating test case. Error message: ${errors[0].message}`);
}
return data.updateIssue?.issue;
})
.catch(error => {
createFlash({
message: errorMessage,
captureError: true,
error,
});
});
},
/**
* We're using Public REST API to add Test Case as a Todo since
* GraphQL mutation to do the same is unavailable as of now.
*/
addTestCaseAsTodo() {
this.testCaseTodoUpdateInProgress = true;
return Api.addProjectIssueAsTodo(this.projectFullPath, this.testCaseId)
.then(() => {
this.$apollo.queries.testCase.refetch();
})
.catch(error => {
createFlash({
message: s__('TestCases|Something went wrong while adding test case to Todo.'),
captureError: true,
error,
});
})
.finally(() => {
this.testCaseTodoUpdateInProgress = false;
});
},
markTestCaseTodoDone() {
this.testCaseTodoUpdateInProgress = true;
return this.$apollo
.mutate({
mutation: markTestCaseTodoDone,
variables: {
todoMarkDoneInput: {
id: this.todo.id,
},
},
})
.then(({ data = {} }) => {
const errors = data.todoMarkDone?.errors;
if (errors?.length) {
throw new Error(`Error marking todo as done. Error message: ${errors[0].message}`);
}
this.$apollo.queries.testCase.refetch();
})
.catch(error => {
createFlash({
message: s__('TestCases|Something went wrong while marking test case todo as done.'),
captureError: true,
error,
});
})
.finally(() => {
this.testCaseTodoUpdateInProgress = false;
});
},
},
};
mutation markTestCaseTodoDone($todoMarkDoneInput: TodoMarkDoneInput!) {
todoMarkDone(input: $todoMarkDoneInput) {
errors
clientMutationId
todo {
id
}
}
}
#import "./test_case.fragment.graphql"
query projectTestCase($projectPath: ID!, $testCaseId: String) {
project(fullPath: $projectPath) {
name
issue(iid: $testCaseId) {
...TestCase
}
}
}
#import "~/graphql_shared/fragments/author.fragment.graphql"
#import "~/graphql_shared/fragments/label.fragment.graphql"
fragment TestCase on Issue {
id
title
titleHtml
description
descriptionHtml
state
createdAt
updatedAt
webUrl
blocked
confidential
author {
...Author
}
labels {
nodes {
...Label
}
}
currentUserTodos(first: 1) {
nodes {
id
state
}
}
}
#import "./test_case.fragment.graphql"
mutation updateTestCase($updateTestCaseInput: UpdateIssueInput!) {
updateIssue(input: $updateTestCaseInput) {
clientMutationId
errors
issue {
...TestCase
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import TestCaseShowApp from './components/test_case_show_root.vue';
Vue.use(VueApollo);
export default function initTestCaseShow({ mountPointSelector }) {
const el = document.querySelector(mountPointSelector);
if (!el) {
return null;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
provide: {
...el.dataset,
canEditTestCase: parseBoolean(el.dataset.canEditTestCase),
},
render: createElement => createElement(TestCaseShowApp),
});
}
...@@ -5,5 +5,12 @@ ...@@ -5,5 +5,12 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", _('Test Cases') - page_title "#{@issue.title} (#{@issue.to_reference})", _('Test Cases')
- page_description @issue.description - page_description @issue.description
-# haml-lint:disable InlineJavaScript #js-issuable-app{ data: { initial: issuable_initial_data(@issue).to_json,
%script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue).to_json can_edit_test_case: can?(current_user, :admin_issue, @project).to_s,
description_preview_path: preview_markdown_path(@project),
description_help_path: help_page_path('user/markdown'),
project_full_path: @project.full_path,
labels_manage_path: project_labels_path(@project),
labels_fetch_path: project_labels_path(@project, format: :json),
test_case_new_path: new_project_quality_test_case_path(@project),
test_case_id: @issue.iid } }
# 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_bug) { create(:label, project: project, title: 'bug') }
let_it_be(:label_doc) { create(:label, project: project, title: 'documentation') }
let_it_be(:test_case) { create(:quality_test_case, project: project, author: user, description: 'Sample description', created_at: 5.days.ago, updated_at: 2.days.ago, labels: [label_bug]) }
before do
project.add_developer(user)
stub_licensed_features(quality_management: true)
sign_in(user)
end
context 'test case page' do
before do
visit project_issue_path(project, test_case)
wait_for_all_requests
end
context 'header' do
it 'shows status, created date and author' do
page.within('.test-case-container .detail-page-header-body') do
expect(page.find('.issuable-status-box')).to have_content('Open')
expect(page.find('.issuable-meta')).to have_content('Opened 5 days ago')
expect(page.find('.issuable-meta')).to have_link(user.name)
end
end
it 'shows action buttons' do
page.within('.test-case-container .detail-page-header-actions') do
expect(page).to have_selector('.dropdown', visible: false)
expect(page).to have_button('Archive test case')
expect(page).to have_link('New test case', href: new_project_quality_test_case_path(project))
end
end
it 'archives test case' do
page.within('.test-case-container') do
click_button 'Archive test case'
wait_for_requests
expect(page.find('.issuable-status-box')).to have_content('Archived')
expect(page).to have_button('Reopen test case')
end
end
end
context 'body' do
it 'shows title, description and edit button' do
page.within('.test-case-container .issuable-details') do
expect(page.find('.title')).to have_content(test_case.title)
expect(page.find('.description')).to have_content(test_case.description)
expect(page).to have_selector('button.js-issuable-edit')
end
end
it 'makes title and description editable on edit click' do
find('.test-case-container .issuable-details .js-issuable-edit').click
page.within('.test-case-container .issuable-details form') do
expect(page.find('input#issuable-title').value).to eq(test_case.title)
expect(page.find('textarea#issuable-description').value).to eq(test_case.description)
expect(page).to have_button('Save changes')
expect(page).to have_button('Cancel')
end
end
it 'update title and description' do
title = 'Updated title'
description = 'Updated test case description.'
find('.test-case-container .issuable-details .js-issuable-edit').click
page.within('.test-case-container .issuable-details form') do
page.find('input#issuable-title').set(title)
page.find('textarea#issuable-description').set(description)
click_button 'Save changes'
end
wait_for_requests
page.within('.test-case-container .issuable-details') do
expect(page.find('.title')).to have_content(title)
expect(page.find('.description')).to have_content(description)
expect(page.find('.edited-text')).to have_content('')
end
end
end
context 'sidebar' do
it 'shows expand/collapse button' do
page.within('.test-case-container .right-sidebar') do
expect(page).to have_button('Collapse sidebar')
end
end
context 'todo' do
it 'shows todo status' do
page.within('.test-case-container .issuable-sidebar') do
expect(page.find('.block.todo')).to have_content('To Do')
expect(page).to have_button('Add a to do')
end
end
it 'add test case as todo' do
page.within('.test-case-container .issuable-sidebar') do
click_button 'Add a to do'
wait_for_all_requests
expect(page).to have_button('Mark as done')
end
end
it 'mark test case todo as done' do
page.within('.test-case-container .issuable-sidebar') do
click_button 'Add a to do'
wait_for_all_requests
click_button 'Mark as done'
wait_for_all_requests
expect(page).to have_button('Add a to do')
end
end
end
context 'labels' do
it 'shows assigned labels' do
page.within('.test-case-container .issuable-sidebar') do
expect(page).to have_selector('.labels-select-wrapper')
expect(page.find('.labels-select-wrapper .value')).to have_content(label_bug.title)
end
end
it 'shows labels dropdown on edit click' do
page.within('.test-case-container .issuable-sidebar .labels-select-wrapper') do
click_button 'Edit'
wait_for_requests
expect(page.find('.js-labels-list .dropdown-content')).to have_selector('li', count: 2)
expect(page.find('.js-labels-list .dropdown-footer')).to have_selector('li', count: 2)
end
end
it 'applies label using labels dropdown' do
page.within('.test-case-container .issuable-sidebar .labels-select-wrapper') do
click_button 'Edit'
wait_for_requests
click_link label_doc.title
click_button 'Edit'
wait_for_requests
expect(page.find('.labels-select-wrapper .value')).to have_content(label_doc.title)
end
end
end
end
end
end
import { shallowMount } from '@vue/test-utils';
import { GlButton, GlIcon } from '@gitlab/ui';
import Mousetrap from 'mousetrap';
import { mockCurrentUserTodo, mockLabels } from 'jest/issuable_list/mock_data';
import TestCaseSidebar from 'ee/test_case_show/components/test_case_sidebar.vue';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import { mockProvide, mockTestCase } from '../mock_data';
const createComponent = ({
sidebarExpanded = true,
todo = mockCurrentUserTodo,
selectedLabels = mockLabels,
testCaseLoading = false,
} = {}) =>
shallowMount(TestCaseSidebar, {
provide: {
...mockProvide,
},
propsData: {
sidebarExpanded,
todo,
selectedLabels,
},
mocks: {
$apollo: {
queries: {
testCase: {
loading: testCaseLoading,
},
},
},
},
});
describe('TestCaseSidebar', () => {
let mousetrapSpy;
let wrapper;
beforeEach(() => {
mousetrapSpy = jest.spyOn(Mousetrap, 'bind');
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe.each`
state | isTodoPending | todoActionText | todoIcon
${'pending'} | ${true} | ${'Mark as done'} | ${'todo-done'}
${'done'} | ${false} | ${'Add a to do'} | ${'todo-add'}
`('when `todo.state` is "$state"', ({ state, isTodoPending, todoActionText, todoIcon }) => {
beforeEach(async () => {
wrapper.setProps({
todo: {
...mockCurrentUserTodo,
state,
},
});
await wrapper.vm.$nextTick();
});
it.each`
propName | propValue
${'isTodoPending'} | ${isTodoPending}
${'todoActionText'} | ${todoActionText}
${'todoIcon'} | ${todoIcon}
`('computed prop `$propName` returns $propValue', ({ propName, propValue }) => {
expect(wrapper.vm[propName]).toBe(propValue);
});
});
});
describe('mounted', () => {
it('binds key-press listener for `l` on Mousetrap', () => {
expect(mousetrapSpy).toHaveBeenCalledWith('l', wrapper.vm.handleLabelsCollapsedButtonClick);
});
});
describe('methods', () => {
describe('handleTodoButtonClick', () => {
it.each`
state | methodToCall
${'pending'} | ${'markTestCaseTodoDone'}
${'done'} | ${'addTestCaseAsTodo'}
`(
'calls `wrapper.vm.$methodToCall` when `todo.state` is "$state"',
async ({ state, methodToCall }) => {
jest.spyOn(wrapper.vm, methodToCall).mockImplementation(jest.fn());
wrapper.setProps({
todo: {
...mockCurrentUserTodo,
state,
},
});
await wrapper.vm.$nextTick();
wrapper.vm.handleTodoButtonClick();
expect(wrapper.vm[methodToCall]).toHaveBeenCalled();
},
);
});
describe('toggleSidebar', () => {
beforeEach(() => {
setFixtures('<button class="js-toggle-right-sidebar-button"></button>');
});
it('dispatches click event on sidebar toggle button', () => {
const buttonEl = document.querySelector('.js-toggle-right-sidebar-button');
jest.spyOn(buttonEl, 'dispatchEvent');
wrapper.vm.toggleSidebar();
expect(buttonEl.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'click',
}),
);
});
});
describe('handleLabelsDropdownClose', () => {
it('sets `sidebarExpandedOnClick` to false and calls `toggleSidebar` method when `sidebarExpandedOnClick` is true', async () => {
jest.spyOn(wrapper.vm, 'toggleSidebar').mockImplementation(jest.fn());
wrapper.setData({
sidebarExpandedOnClick: true,
});
await wrapper.vm.$nextTick();
wrapper.vm.handleLabelsDropdownClose();
expect(wrapper.vm.sidebarExpandedOnClick).toBe(false);
expect(wrapper.vm.toggleSidebar).toHaveBeenCalled();
});
});
describe('handleLabelsCollapsedButtonClick', () => {
beforeEach(() => {
setFixtures(`
<div class="js-labels-block">
<button class="js-sidebar-dropdown-toggle"></button>
</div>
`);
});
it('calls `toggleSidebar` method and sets `sidebarExpandedOnClick` to true when `sidebarExpanded` prop is false', async () => {
jest.spyOn(wrapper.vm, 'toggleSidebar').mockImplementation(jest.fn());
wrapper.setProps({
sidebarExpanded: false,
});
await wrapper.vm.$nextTick();
wrapper.vm.handleLabelsCollapsedButtonClick();
expect(wrapper.vm.toggleSidebar).toHaveBeenCalled();
expect(wrapper.vm.sidebarExpandedOnClick).toBe(true);
});
it('dispatches click event on label edit button', async () => {
const buttonEl = document.querySelector('.js-sidebar-dropdown-toggle');
jest.spyOn(wrapper.vm, 'toggleSidebar').mockImplementation(jest.fn());
jest.spyOn(buttonEl, 'dispatchEvent');
wrapper.setProps({
sidebarExpanded: false,
});
await wrapper.vm.$nextTick();
wrapper.vm.handleLabelsCollapsedButtonClick();
await wrapper.vm.$nextTick();
expect(buttonEl.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'click',
bubbles: true,
cancelable: false,
}),
);
});
});
describe('handleUpdateSelectedLabels', () => {
const updatedLabels = [
{
...mockLabels[0],
set: false,
},
];
it('sets `testCaseLabelsSelectInProgress` to true when provided labels param includes any of the additions or removals', () => {
jest.spyOn(wrapper.vm, 'updateTestCase').mockResolvedValue(mockTestCase);
wrapper.vm.handleUpdateSelectedLabels(updatedLabels);
expect(wrapper.vm.testCaseLabelsSelectInProgress).toBe(true);
});
it('calls `updateTestCase` method with variables `addLabelIds` & `removeLabelIds` and erroMessage when provided labels param includes any of the additions or removals', () => {
jest.spyOn(wrapper.vm, 'updateTestCase').mockResolvedValue(mockTestCase);
wrapper.vm.handleUpdateSelectedLabels(updatedLabels);
expect(wrapper.vm.updateTestCase).toHaveBeenCalledWith({
variables: {
addLabelIds: [],
removeLabelIds: [updatedLabels[0].id],
},
errorMessage: 'Something went wrong while updating the test case labels.',
});
});
it('emits "test-case-updated" event on component upon promise resolve', () => {
jest.spyOn(wrapper.vm, 'updateTestCase').mockResolvedValue(mockTestCase);
jest.spyOn(wrapper.vm, '$emit');
return wrapper.vm.handleUpdateSelectedLabels(updatedLabels).then(() => {
expect(wrapper.vm.$emit).toHaveBeenCalledWith('test-case-updated', mockTestCase);
});
});
it('sets `testCaseLabelsSelectInProgress` to false', () => {
jest.spyOn(wrapper.vm, 'updateTestCase').mockResolvedValue(mockTestCase);
return wrapper.vm.handleUpdateSelectedLabels(updatedLabels).finally(() => {
expect(wrapper.vm.testCaseLabelsSelectInProgress).toBe(false);
});
});
});
});
describe('template', () => {
it('renders todo button', async () => {
let todoEl = wrapper.find('[data-testid="todo"]');
expect(todoEl.exists()).toBe(true);
expect(todoEl.text()).toContain('To Do');
expect(todoEl.find(GlButton).exists()).toBe(true);
expect(todoEl.find(GlButton).text()).toBe('Add a to do');
wrapper.setProps({
sidebarExpanded: false,
});
await wrapper.vm.$nextTick();
todoEl = wrapper.find('button');
expect(todoEl.exists()).toBe(true);
expect(todoEl.attributes('title')).toBe('Add a to do');
expect(todoEl.find(GlIcon).exists()).toBe(true);
});
it('renders label-select', async () => {
const { selectedLabels, testCaseLabelsSelectInProgress } = wrapper.vm;
const { canEditTestCase, labelsFetchPath, labelsManagePath } = mockProvide;
const labelSelectEl = wrapper.find(LabelsSelect);
expect(labelSelectEl.exists()).toBe(true);
expect(labelSelectEl.props()).toMatchObject({
selectedLabels,
labelsFetchPath,
labelsManagePath,
allowLabelCreate: true,
allowMultiselect: true,
variant: 'sidebar',
allowLabelEdit: canEditTestCase,
labelsSelectInProgress: testCaseLabelsSelectInProgress,
});
expect(labelSelectEl.text()).toBe('None');
});
});
});
import { shallowMount } from '@vue/test-utils';
import { mockCurrentUserTodo } from 'jest/issuable_list/mock_data';
import TestCaseShowRoot from 'ee/test_case_show/components/test_case_show_root.vue';
import updateTestCase from 'ee/test_case_show/queries/update_test_case.mutation.graphql';
import markTestCaseTodoDone from 'ee/test_case_show/queries/mark_test_case_todo_done.mutation.graphql';
import createFlash from '~/flash';
import Api from '~/api';
import { mockProvide, mockTestCase } from '../mock_data';
jest.mock('~/flash');
const createComponent = ({ testCase, testCaseQueryLoading = false } = {}) =>
shallowMount(TestCaseShowRoot, {
provide: {
...mockProvide,
},
mocks: {
$apollo: {
queries: {
testCase: {
loading: testCaseQueryLoading,
refetch: jest.fn(),
},
},
mutate: jest.fn(),
},
},
data() {
return {
testCaseLoading: testCaseQueryLoading,
testCase: testCaseQueryLoading
? {}
: {
...mockTestCase,
...testCase,
},
};
},
});
describe('TestCaseGraphQL Mixin', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('updateTestCase', () => {
it('calls `$apollo.mutate` with updateTestCase mutation and updateTestCaseInput variables', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: {
updateIssue: {
errors: [],
issue: mockTestCase,
},
},
});
wrapper.vm.updateTestCase({
variables: {
title: 'Foo',
},
errorMessage: 'Something went wrong',
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateTestCase,
variables: {
updateTestCaseInput: {
projectPath: mockProvide.projectFullPath,
iid: mockProvide.testCaseId,
title: 'Foo',
},
},
});
});
it('calls `createFlash` with errorMessage on promise reject', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({});
return wrapper.vm
.updateTestCase({
variables: {
title: 'Foo',
},
errorMessage: 'Something went wrong',
})
.then(() => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong',
captureError: true,
error: expect.any(Object),
});
});
});
});
describe('addTestCaseAsTodo', () => {
it('sets `testCaseTodoUpdateInProgress` to true', () => {
jest.spyOn(Api, 'addProjectIssueAsTodo').mockResolvedValue({});
wrapper.vm.addTestCaseAsTodo();
expect(wrapper.vm.testCaseTodoUpdateInProgress).toBe(true);
});
it('calls `Api.addProjectIssueAsTodo` method with params `projectFullPath` and `testCaseId`', () => {
jest.spyOn(Api, 'addProjectIssueAsTodo').mockResolvedValue({});
wrapper.vm.addTestCaseAsTodo();
expect(Api.addProjectIssueAsTodo).toHaveBeenCalledWith(
mockProvide.projectFullPath,
mockProvide.testCaseId,
);
});
it('calls `$apollo.queries.testCase.refetch` method on request promise resolve', () => {
jest.spyOn(Api, 'addProjectIssueAsTodo').mockResolvedValue({});
jest.spyOn(wrapper.vm.$apollo.queries.testCase, 'refetch');
return wrapper.vm.addTestCaseAsTodo().then(() => {
expect(wrapper.vm.$apollo.queries.testCase.refetch).toHaveBeenCalled();
});
});
it('calls `createFlash` method on request promise reject', () => {
jest.spyOn(Api, 'addProjectIssueAsTodo').mockRejectedValue({});
return wrapper.vm.addTestCaseAsTodo().then(() => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while adding test case to Todo.',
captureError: true,
error: expect.any(Object),
});
});
});
it('sets `testCaseTodoUpdateInProgress` to false on request promise resolve or reject', () => {
jest.spyOn(Api, 'addProjectIssueAsTodo').mockRejectedValue({});
return wrapper.vm.addTestCaseAsTodo().finally(() => {
expect(wrapper.vm.testCaseTodoUpdateInProgress).toBe(false);
});
});
});
describe('markTestCaseTodoDone', () => {
const todoResolvedMutation = {
data: {
todoMarkDone: {
errors: [],
},
},
};
it('sets `testCaseTodoUpdateInProgress` to true', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(todoResolvedMutation);
wrapper.vm.markTestCaseTodoDone();
expect(wrapper.vm.testCaseTodoUpdateInProgress).toBe(true);
});
it('calls `$apollo.mutate` with markTestCaseTodoDone mutation and todoMarkDoneInput variables', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(todoResolvedMutation);
wrapper.vm.markTestCaseTodoDone();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: markTestCaseTodoDone,
variables: {
todoMarkDoneInput: {
id: mockCurrentUserTodo.id,
},
},
});
});
it('calls `$apollo.queries.testCase.refetch` on mutation promise resolve', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(todoResolvedMutation);
jest.spyOn(wrapper.vm.$apollo.queries.testCase, 'refetch');
return wrapper.vm.markTestCaseTodoDone().then(() => {
expect(wrapper.vm.$apollo.queries.testCase.refetch).toHaveBeenCalled();
});
});
it('calls `createFlash` with errorMessage on mutation promise reject', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({});
return wrapper.vm.markTestCaseTodoDone().then(() => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while marking test case todo as done.',
captureError: true,
error: expect.any(Object),
});
});
});
it('sets `testCaseTodoUpdateInProgress` to false on mutation promise resolve or reject', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(todoResolvedMutation);
return wrapper.vm.markTestCaseTodoDone().finally(() => {
expect(wrapper.vm.testCaseTodoUpdateInProgress).toBe(false);
});
});
});
});
import { mockIssuable, mockCurrentUserTodo } from 'jest/issuable_list/mock_data';
export const mockTestCase = {
...mockIssuable,
currentUserTodos: {
nodes: [mockCurrentUserTodo],
},
};
export const mockProvide = {
projectFullPath: 'gitlab-org/gitlab-test',
testCaseNewPath: '/gitlab-org/gitlab-test/-/quality/test_cases/new',
testCaseId: mockIssuable.iid,
canEditTestCase: true,
descriptionPreviewPath: '/gitlab-org/gitlab-test/preview_markdown',
descriptionHelpPath: '/help/user/markdown',
labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json',
labelsManagePath: '/gitlab-org/gitlab-shell/-/labels',
};
...@@ -3425,6 +3425,9 @@ msgstr "" ...@@ -3425,6 +3425,9 @@ msgstr ""
msgid "Archive project" msgid "Archive project"
msgstr "" msgstr ""
msgid "Archive test case"
msgstr ""
msgid "Archived" msgid "Archived"
msgstr "" msgstr ""
...@@ -22070,6 +22073,9 @@ msgstr "" ...@@ -22070,6 +22073,9 @@ msgstr ""
msgid "Reopen milestone" msgid "Reopen milestone"
msgstr "" msgstr ""
msgid "Reopen test case"
msgstr ""
msgid "Reopen this %{quick_action_target}" msgid "Reopen this %{quick_action_target}"
msgstr "" msgstr ""
...@@ -25897,15 +25903,30 @@ msgstr "" ...@@ -25897,15 +25903,30 @@ msgstr ""
msgid "TestCases|Search test cases" msgid "TestCases|Search test cases"
msgstr "" msgstr ""
msgid "TestCases|Something went wrong while adding test case to Todo."
msgstr ""
msgid "TestCases|Something went wrong while creating a test case." msgid "TestCases|Something went wrong while creating a test case."
msgstr "" msgstr ""
msgid "TestCases|Something went wrong while fetching count of test cases." msgid "TestCases|Something went wrong while fetching count of test cases."
msgstr "" msgstr ""
msgid "TestCases|Something went wrong while fetching test case."
msgstr ""
msgid "TestCases|Something went wrong while fetching test cases list." msgid "TestCases|Something went wrong while fetching test cases list."
msgstr "" msgstr ""
msgid "TestCases|Something went wrong while marking test case todo as done."
msgstr ""
msgid "TestCases|Something went wrong while updating the test case labels."
msgstr ""
msgid "TestCases|Something went wrong while updating the test case."
msgstr ""
msgid "TestCases|Submit test case" msgid "TestCases|Submit test case"
msgstr "" msgstr ""
......
...@@ -421,6 +421,25 @@ describe('Api', () => { ...@@ -421,6 +421,25 @@ describe('Api', () => {
}); });
}); });
describe('addProjectIssueAsTodo', () => {
it('adds issue ID as a todo', () => {
const projectId = 1;
const issueIid = 11;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/issues/11/todo`;
mock.onPost(expectedUrl).reply(200, {
id: 112,
project: {
id: 1,
},
});
return Api.addProjectIssueAsTodo(projectId, issueIid).then(({ data }) => {
expect(data.id).toBe(112);
expect(data.project.id).toBe(projectId);
});
});
});
describe('newLabel', () => { describe('newLabel', () => {
it('creates a new label', done => { it('creates a new label', done => {
const namespace = 'some namespace'; const namespace = 'some namespace';
......
...@@ -30,13 +30,23 @@ export const mockScopedLabel = { ...@@ -30,13 +30,23 @@ export const mockScopedLabel = {
export const mockLabels = [mockRegularLabel, mockScopedLabel]; export const mockLabels = [mockRegularLabel, mockScopedLabel];
export const mockCurrentUserTodo = {
id: 'gid://gitlab/Todo/489',
state: 'done',
};
export const mockIssuable = { export const mockIssuable = {
iid: '30', iid: '30',
title: 'Dismiss Cipher with no integrity', title: 'Dismiss Cipher with no integrity',
description: null, titleHtml: 'Dismiss Cipher with no integrity',
description: 'fortitudinis _fomentis_ dolor mitigari solet.',
descriptionHtml: 'fortitudinis <i>fomentis</i> dolor mitigari solet.',
state: 'opened',
createdAt: '2020-06-29T13:52:56Z', createdAt: '2020-06-29T13:52:56Z',
updatedAt: '2020-09-10T11:41:13Z', updatedAt: '2020-09-10T11:41:13Z',
webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/30', webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/30',
blocked: false,
confidential: false,
author: mockAuthor, author: mockAuthor,
labels: { labels: {
nodes: mockLabels, nodes: mockLabels,
......
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