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 {
<div data-testid="issuable-title" class="form-group row">
<label for="issuable-title" class="col-form-label col-sm-2">{{ __('Title') }}</label>
<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 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
class Projects::Quality::TestCasesController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:new]
before_action :check_quality_management_available!
before_action :authorize_read_issue!
before_action :verify_test_cases_flag!
before_action :authorize_create_issue!, only: [:new]
before_action do
push_frontend_feature_flag(:quality_test_cases, project)
end
......@@ -14,6 +18,12 @@ class Projects::Quality::TestCasesController < Projects::ApplicationController
end
end
def new
respond_to do |format|
format.html
end
end
private
def verify_test_cases_flag!
......
- breadcrumb_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
end
namespace :quality do
resources :test_cases, only: [:index]
resources :test_cases, only: [:index, :new]
end
resources :autocomplete_sources, only: [] do
......
......@@ -6,9 +6,7 @@ RSpec.describe Projects::Quality::TestCasesController do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
subject { get :index, params: { namespace_id: project.namespace, project_id: project } }
describe 'GET #index' do
shared_examples_for 'test case action' do |template|
context 'with authorized user' do
before do
project.add_developer(user)
......@@ -20,11 +18,11 @@ RSpec.describe Projects::Quality::TestCasesController do
stub_licensed_features(quality_management: true)
end
it 'renders the index template' do
it 'renders the template' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
expect(response).to render_template(template)
end
context 'when quality_test_cases flag is disabled' do
......@@ -80,4 +78,18 @@ RSpec.describe Projects::Quality::TestCasesController do
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
# 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 ""
msgid "New Snippet"
msgstr ""
msgid "New Test Case"
msgstr ""
msgid "New User"
msgstr ""
......@@ -23622,6 +23625,9 @@ msgstr ""
msgid "Something went wrong while creating a requirement."
msgstr ""
msgid "Something went wrong while creating a test case."
msgstr ""
msgid "Something went wrong while deleting description changes. Please try again."
msgstr ""
......@@ -24360,6 +24366,9 @@ msgstr ""
msgid "Submit search"
msgstr ""
msgid "Submit test case"
msgstr ""
msgid "Submit the current review."
msgstr ""
......
......@@ -65,6 +65,7 @@ describe('IssuableForm', () => {
expect(titleFieldEl.find('label').text()).toBe('Title');
expect(titleFieldEl.find(GlFormInput).exists()).toBe(true);
expect(titleFieldEl.find(GlFormInput).attributes('placeholder')).toBe('Title');
expect(titleFieldEl.find(GlFormInput).attributes('autofocus')).toBe('true');
});
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