Commit 655c99d6 authored by Simon Knox's avatar Simon Knox

Merge branch '233479-add-new-test-case-page' into 'master'

Add Create Test Case form

See merge request gitlab-org/gitlab!41559
parents 2be29ddc cac4831e
...@@ -53,7 +53,12 @@ export default { ...@@ -53,7 +53,12 @@ export default {
<div data-testid="issuable-title" class="form-group row"> <div data-testid="issuable-title" class="form-group row">
<label for="issuable-title" class="col-form-label col-sm-2">{{ __('Title') }}</label> <label for="issuable-title" class="col-form-label col-sm-2">{{ __('Title') }}</label>
<div class="col-sm-10"> <div class="col-sm-10">
<gl-form-input id="issuable-title" v-model="issuableTitle" :placeholder="__('Title')" /> <gl-form-input
id="issuable-title"
v-model="issuableTitle"
:autofocus="true"
:placeholder="__('Title')"
/>
</div> </div>
</div> </div>
<div data-testid="issuable-description" class="form-group row"> <div data-testid="issuable-description" class="form-group row">
......
import { initTestCaseCreate } from 'ee/test_case_create/test_case_create_bundle';
document.addEventListener('DOMContentLoaded', () => {
initTestCaseCreate({
mountPointSelector: '#js-create-test-case',
});
});
<script>
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import IssuableCreate from '~/issuable_create/components/issuable_create_root.vue';
import createTestCase from '../queries/create_test_case.mutation.graphql';
export default {
components: {
GlButton,
IssuableCreate,
},
inject: [
'projectFullPath',
'projectTestCasesPath',
'descriptionPreviewPath',
'descriptionHelpPath',
'labelsFetchPath',
'labelsManagePath',
],
data() {
return {
createTestCaseRequestActive: false,
};
},
methods: {
handleTestCaseSubmitClick({ issuableTitle, issuableDescription, selectedLabels }) {
this.createTestCaseRequestActive = true;
return this.$apollo
.mutate({
mutation: createTestCase,
variables: {
createTestCaseInput: {
projectPath: this.projectFullPath,
title: issuableTitle,
description: issuableDescription,
labelIds: selectedLabels.map(label => label.id),
},
},
})
.then(({ data = {} }) => {
const errors = data.createTestCase?.errors;
if (errors?.length) {
throw new Error(`Error creating a test case. Error message: ${errors[0].message}`);
}
redirectTo(this.projectTestCasesPath);
})
.catch(error => {
createFlash({
message: __('Something went wrong while creating a test case.'),
captureError: true,
error,
});
})
.finally(() => {
this.createTestCaseRequestActive = false;
});
},
},
};
</script>
<template>
<issuable-create
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
:labels-fetch-path="labelsFetchPath"
:labels-manage-path="labelsManagePath"
>
<template #title>
<h3 class="page-title">{{ __('New Test Case') }}</h3>
</template>
<template #actions="issuableMeta">
<div class="gl-flex-grow-1">
<gl-button
data-testid="submit-test-case"
category="primary"
variant="success"
:loading="createTestCaseRequestActive"
:disabled="!issuableMeta.issuableTitle.length"
@click="handleTestCaseSubmitClick(issuableMeta)"
>{{ __('Submit test case') }}</gl-button
>
</div>
<gl-button
data-testid="cancel-test-case"
:disabled="createTestCaseRequestActive"
:href="projectTestCasesPath"
>{{ __('Cancel') }}</gl-button
>
</template>
</issuable-create>
</template>
mutation createTestCase($createTestCaseInput: CreateTestCaseInput!) {
createTestCase(input: $createTestCaseInput) {
clientMutationId
errors
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import TestCaseCreateApp from './components/test_case_create_root.vue';
Vue.use(VueApollo);
export function initTestCaseCreate({ mountPointSelector }) {
const mountPointEl = document.querySelector(mountPointSelector);
if (!mountPointEl) {
return null;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el: mountPointEl,
apolloProvider,
provide: {
...mountPointEl.dataset,
},
render: createElement => createElement(TestCaseCreateApp),
});
}
# frozen_string_literal: true # frozen_string_literal: true
class Projects::Quality::TestCasesController < Projects::ApplicationController class Projects::Quality::TestCasesController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:new]
before_action :check_quality_management_available! before_action :check_quality_management_available!
before_action :authorize_read_issue! before_action :authorize_read_issue!
before_action :verify_test_cases_flag! before_action :verify_test_cases_flag!
before_action :authorize_create_issue!, only: [:new]
before_action do before_action do
push_frontend_feature_flag(:quality_test_cases, project) push_frontend_feature_flag(:quality_test_cases, project)
end end
...@@ -14,6 +18,12 @@ class Projects::Quality::TestCasesController < Projects::ApplicationController ...@@ -14,6 +18,12 @@ class Projects::Quality::TestCasesController < Projects::ApplicationController
end end
end end
def new
respond_to do |format|
format.html
end
end
private private
def verify_test_cases_flag! def verify_test_cases_flag!
......
- breadcrumb_title _('Test Cases')
- page_title _('Test Cases') - page_title _('Test Cases')
- breadcrumb_title _("Test Cases") - @content_class = 'project-test-cases'
- add_to_breadcrumbs _('Test Cases'), project_quality_test_cases_path(@project)
- breadcrumb_title _('New')
- page_title _('New Test Case')
#js-create-test-case{ data: { project_full_path: @project.full_path,
project_test_cases_path: project_quality_test_cases_path(@project),
description_preview_path: preview_markdown_path(@project),
description_help_path: help_page_path('user/markdown'),
labels_manage_path: project_labels_path(@project),
labels_fetch_path: project_labels_path(@project, format: :json) } }
...@@ -16,7 +16,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -16,7 +16,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end end
namespace :quality do namespace :quality do
resources :test_cases, only: [:index] resources :test_cases, only: [:index, :new]
end end
resources :autocomplete_sources, only: [] do resources :autocomplete_sources, only: [] do
......
...@@ -6,9 +6,7 @@ RSpec.describe Projects::Quality::TestCasesController do ...@@ -6,9 +6,7 @@ RSpec.describe Projects::Quality::TestCasesController do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
subject { get :index, params: { namespace_id: project.namespace, project_id: project } } shared_examples_for 'test case action' do |template|
describe 'GET #index' do
context 'with authorized user' do context 'with authorized user' do
before do before do
project.add_developer(user) project.add_developer(user)
...@@ -20,11 +18,11 @@ RSpec.describe Projects::Quality::TestCasesController do ...@@ -20,11 +18,11 @@ RSpec.describe Projects::Quality::TestCasesController do
stub_licensed_features(quality_management: true) stub_licensed_features(quality_management: true)
end end
it 'renders the index template' do it 'renders the template' do
subject subject
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index) expect(response).to render_template(template)
end end
context 'when quality_test_cases flag is disabled' do context 'when quality_test_cases flag is disabled' do
...@@ -80,4 +78,18 @@ RSpec.describe Projects::Quality::TestCasesController do ...@@ -80,4 +78,18 @@ RSpec.describe Projects::Quality::TestCasesController do
end end
end end
end end
describe 'GET' do
describe '#index' do
subject { get :index, params: { namespace_id: project.namespace, project_id: project } }
it_behaves_like 'test case action', :index
end
describe '#new' do
subject { get :new, params: { namespace_id: project.namespace, project_id: project } }
it_behaves_like 'test case action', :new
end
end
end end
# 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(:label1) { create(:label, project: project, title: 'bug') }
let_it_be(:label2) { create(:label, project: project, title: 'enhancement') }
let_it_be(:label3) { create(:label, project: project, title: 'documentation') }
before do
project.add_developer(user)
stub_licensed_features(quality_management: true)
sign_in(user)
end
context 'test case create form' do
before do
visit new_project_quality_test_case_path(project)
wait_for_requests
end
it 'shows page title, title, description and label input fields' do
page.within('.issuable-create-container') do
expect(page.find('.page-title')).to have_content('New Test Case')
end
page.within('.issuable-create-container form') do
form_fields = page.find_all('.form-group.row')
expect(form_fields[0].find('label')).to have_content('Title')
expect(form_fields[0]).to have_selector('input#issuable-title')
expect(form_fields[1].find('label')).to have_content('Description')
expect(form_fields[1]).to have_selector('.js-vue-markdown-field')
expect(form_fields[2].find('label')).to have_content('Labels')
expect(form_fields[2]).to have_selector('.labels-select-wrapper')
end
end
it 'shows labels and footer actions within labels dropdown' do
page.within('.issuable-create-container form .labels-select-wrapper') do
page.find('.js-dropdown-button').click
wait_for_requests
expect(page.find('.js-labels-list .dropdown-content')).to have_selector('li', count: 3)
expect(page.find('.js-labels-list .dropdown-footer')).to have_selector('li', count: 2)
end
end
it 'shows page actions' do
page.within('.issuable-create-container .footer-block') do
expect(page.find('button')).to have_content('Submit test case')
expect(page.find('a')).to have_content('Cancel')
end
end
it 'creates a test case on saving form' do
title = 'Sample title'
description = 'Sample _test case_ description.'
page.within('.issuable-create-container form') do
form_fields = page.find_all('.form-group.row')
form_fields[0].find('input#issuable-title').native.send_keys title
form_fields[1].find('textarea#issuable-description').native.send_keys description
form_fields[2].find('.js-dropdown-button').click
wait_for_requests
form_fields[2].find_all('.js-labels-list .dropdown-content li')[0].click
end
click_button 'Submit test case'
wait_for_requests
expect(page).to have_selector('.content-wrapper .project-test-cases')
end
end
end
import { mount } from '@vue/test-utils';
import TestCaseCreateRoot from 'ee/test_case_create/components/test_case_create_root.vue';
import createTestCase from 'ee/test_case_create/queries/create_test_case.mutation.graphql';
import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import IssuableCreate from '~/issuable_create/components/issuable_create_root.vue';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility');
const mockProvide = {
projectFullPath: 'gitlab-org/gitlab-test',
projectTestCasesPath: '/gitlab-org/gitlab-test/-/quality/test_cases',
descriptionPreviewPath: '/gitlab-org/gitlab-test/preview_markdown',
descriptionHelpPath: '/help/user/markdown',
labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json',
labelsManagePath: '/gitlab-org/gitlab-shell/-/labels',
};
const createComponent = () =>
mount(TestCaseCreateRoot, {
provide: mockProvide,
mocks: {
$apollo: {
mutate: jest.fn(),
},
},
});
describe('TestCaseCreateRoot', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('handleTestCaseSubmitClick', () => {
const issuableTitle = 'Sample title';
const issuableDescription = 'Sample _description_.';
const selectedLabels = [
{
id: 1,
set: true,
color: '#BADA55',
text_color: '#FFFFFF',
title: 'Bug',
},
];
const mockCreateMutationResult = {
data: {
createTestCase: {
errors: [],
},
},
};
it('sets `createTestCaseRequestActive` prop to true', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockCreateMutationResult);
wrapper.vm.handleTestCaseSubmitClick({
issuableTitle,
issuableDescription,
selectedLabels,
});
expect(wrapper.vm.createTestCaseRequestActive).toBe(true);
});
it('calls `$apollo.mutate` with `createTestCase` mutation and input variables containing projectPath, title, description and labelIds', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockCreateMutationResult);
wrapper.vm.handleTestCaseSubmitClick({
issuableTitle,
issuableDescription,
selectedLabels,
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: createTestCase,
variables: {
createTestCaseInput: {
projectPath: 'gitlab-org/gitlab-test',
title: issuableTitle,
description: issuableDescription,
labelIds: selectedLabels.map(label => label.id),
},
},
}),
);
});
it('calls `redirectTo` with projectTestCasesPath when mutation is successful', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockCreateMutationResult);
return wrapper.vm
.handleTestCaseSubmitClick({
issuableTitle,
issuableDescription,
selectedLabels,
})
.then(() => {
expect(redirectTo).toHaveBeenCalledWith(mockProvide.projectTestCasesPath);
})
.finally(() => {
expect(wrapper.vm.createTestCaseRequestActive).toBe(false);
});
});
it('calls `createFlash` with message and error captured when mutation fails', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({});
return wrapper.vm
.handleTestCaseSubmitClick({
issuableTitle,
issuableDescription,
selectedLabels,
})
.then(() => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong while creating a test case.',
captureError: true,
error: expect.any(Object),
});
})
.finally(() => {
expect(wrapper.vm.createTestCaseRequestActive).toBe(false);
});
});
});
});
describe('template', () => {
it('renders issuable-create as a root component', () => {
const {
descriptionPreviewPath,
descriptionHelpPath,
labelsFetchPath,
labelsManagePath,
} = mockProvide;
expect(wrapper.find(IssuableCreate).exists()).toBe(true);
expect(wrapper.find(IssuableCreate).props()).toMatchObject({
descriptionPreviewPath,
descriptionHelpPath,
labelsFetchPath,
labelsManagePath,
});
});
it('renders page title', () => {
expect(wrapper.find('h3').text()).toBe('New Test Case');
});
it('renders page actions', () => {
const submitEl = wrapper.find('[data-testid="submit-test-case"]');
const cancelEl = wrapper.find('[data-testid="cancel-test-case"]');
expect(submitEl.text()).toBe('Submit test case');
expect(submitEl.props()).toMatchObject({
loading: false,
disabled: true,
});
expect(cancelEl.text()).toBe('Cancel');
expect(cancelEl.props('disabled')).toBe(false);
expect(cancelEl.attributes('href')).toBe(mockProvide.projectTestCasesPath);
});
it('submit button shows loading animation when `createTestCaseRequestActive` is true', async () => {
wrapper.setData({
createTestCaseRequestActive: true,
});
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="submit-test-case"]').props('loading')).toBe(true);
});
it('cancel button is disabled when `createTestCaseRequestActive` is true', async () => {
wrapper.setData({
createTestCaseRequestActive: true,
});
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="cancel-test-case"]').props('disabled')).toBe(true);
});
describe('events', () => {
it('submit button click calls `handleTestCaseSubmitClick` method', () => {
jest.spyOn(wrapper.vm, 'handleTestCaseSubmitClick').mockImplementation(jest.fn);
const submitButton = wrapper.find('[data-testid="submit-test-case"]');
submitButton.vm.$emit('click');
expect(wrapper.vm.handleTestCaseSubmitClick).toHaveBeenCalledWith({
issuableTitle: '',
issuableDescription: '',
selectedLabels: [],
});
});
});
});
});
...@@ -16843,6 +16843,9 @@ msgstr "" ...@@ -16843,6 +16843,9 @@ msgstr ""
msgid "New Snippet" msgid "New Snippet"
msgstr "" msgstr ""
msgid "New Test Case"
msgstr ""
msgid "New User" msgid "New User"
msgstr "" msgstr ""
...@@ -23622,6 +23625,9 @@ msgstr "" ...@@ -23622,6 +23625,9 @@ msgstr ""
msgid "Something went wrong while creating a requirement." msgid "Something went wrong while creating a requirement."
msgstr "" msgstr ""
msgid "Something went wrong while creating a test case."
msgstr ""
msgid "Something went wrong while deleting description changes. Please try again." msgid "Something went wrong while deleting description changes. Please try again."
msgstr "" msgstr ""
...@@ -24360,6 +24366,9 @@ msgstr "" ...@@ -24360,6 +24366,9 @@ msgstr ""
msgid "Submit search" msgid "Submit search"
msgstr "" msgstr ""
msgid "Submit test case"
msgstr ""
msgid "Submit the current review." msgid "Submit the current review."
msgstr "" msgstr ""
......
...@@ -65,6 +65,7 @@ describe('IssuableForm', () => { ...@@ -65,6 +65,7 @@ describe('IssuableForm', () => {
expect(titleFieldEl.find('label').text()).toBe('Title'); expect(titleFieldEl.find('label').text()).toBe('Title');
expect(titleFieldEl.find(GlFormInput).exists()).toBe(true); expect(titleFieldEl.find(GlFormInput).exists()).toBe(true);
expect(titleFieldEl.find(GlFormInput).attributes('placeholder')).toBe('Title'); expect(titleFieldEl.find(GlFormInput).attributes('placeholder')).toBe('Title');
expect(titleFieldEl.find(GlFormInput).attributes('autofocus')).toBe('true');
}); });
it('renders issuable description input field', () => { it('renders issuable description input field', () => {
......
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