Commit c5d3bcff authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'psi-cadence-create-form' into 'master'

Add cadence form FE

See merge request gitlab-org/gitlab!59227
parents e38498d2 6cfe8f32
<script>
import {
GlAlert,
GlButton,
GlDatepicker,
GlForm,
GlFormCheckbox,
GlFormGroup,
GlFormInput,
GlFormSelect,
} from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
import createCadence from '../queries/create_cadence.mutation.graphql';
const i18n = Object.freeze({
title: {
label: s__('Iterations|Title'),
placeholder: s__('Iterations|Cadence name'),
},
automatedScheduling: {
label: s__('Iterations|Automated scheduling'),
description: s__('Iterations|Iteration scheduling will be handled automatically'),
},
startDate: {
label: s__('Iterations|Start date'),
placeholder: s__('Iterations|Select start date'),
description: s__('Iterations|The start date of your first iteration'),
},
duration: {
label: s__('Iterations|Duration'),
description: s__('Iterations|The duration for each iteration (in weeks)'),
placeholder: s__('Iterations|Select duration'),
},
futureIterations: {
label: s__('Iterations|Future iterations'),
description: s__('Iterations|Number of future iterations you would like to have scheduled'),
placeholder: s__('Iterations|Select number'),
},
pageTitle: s__('Iterations|New iteration cadence'),
create: s__('Iterations|Create cadence'),
cancel: __('Cancel'),
requiredField: __('This field is required.'),
});
export default {
availableDurations: [{ value: null, text: i18n.duration.placeholder }, 1, 2, 3, 4, 5, 6],
availableFutureIterations: [
{ value: null, text: i18n.futureIterations.placeholder },
2,
4,
6,
8,
10,
12,
],
components: {
GlAlert,
GlButton,
GlDatepicker,
GlForm,
GlFormCheckbox,
GlFormGroup,
GlFormInput,
GlFormSelect,
},
inject: ['groupPath'],
props: {
cadencesListPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
cadences: [],
loading: false,
errorMessage: '',
title: '',
automatic: true,
startDate: null,
durationInWeeks: null,
rollOverIssues: false,
iterationsInAdvance: null,
validationState: {
title: null,
startDate: null,
durationInWeeks: null,
iterationsInAdvance: null,
},
i18n,
};
},
computed: {
valid() {
return !Object.values(this.validationState).includes(false);
},
variables() {
const vars = {
input: {
groupPath: this.groupPath,
title: this.title,
automatic: this.automatic,
startDate: this.startDate,
durationInWeeks: this.durationInWeeks,
active: true,
},
};
if (this.automatic) {
vars.input = {
...vars.input,
iterationsInAdvance: this.iterationsInAdvance,
};
}
return vars;
},
},
methods: {
validate(field) {
this.validationState[field] = Boolean(this[field]);
},
validateAllFields() {
Object.keys(this.validationState)
.filter((field) => {
if (this.automatic) {
return true;
}
const requiredFieldsForAutomatedScheduling = ['iterationsInAdvance'];
return !requiredFieldsForAutomatedScheduling.includes(field);
})
.forEach((field) => {
this.validate(field);
});
},
clearValidation() {
this.validationState.startDate = null;
this.validationState.durationInWeeks = null;
this.validationState.iterationsInAdvance = null;
},
save() {
this.validateAllFields();
if (!this.valid) {
return null;
}
this.loading = true;
return this.createCadence();
},
cancel() {
if (this.cadencesListPath) {
visitUrl(this.cadencesListPath);
} else {
this.$emit('cancel');
}
},
createCadence() {
return this.$apollo
.mutate({
mutation: createCadence,
variables: this.variables,
})
.then(({ data, errors: topLevelErrors = [] }) => {
if (topLevelErrors.length > 0) {
this.errorMessage = topLevelErrors[0].message;
return;
}
const { errors } = data.iterationCadenceCreate;
if (errors.length > 0) {
[this.errorMessage] = errors;
return;
}
visitUrl(this.cadencesListPath);
})
.catch((e) => {
this.errorMessage = __('Unable to save cadence. Please try again');
throw e;
})
.finally(() => {
this.loading = false;
});
},
},
};
</script>
<template>
<div></div>
<article>
<div class="gl-display-flex">
<h3 ref="pageTitle" class="page-title">
{{ i18n.pageTitle }}
</h3>
</div>
<gl-form>
<gl-alert v-if="errorMessage" class="gl-mb-5" variant="danger" @dismiss="errorMessage = ''">{{
errorMessage
}}</gl-alert>
<gl-form-group
:label="i18n.title.label"
:label-cols-md="2"
label-class="text-right-md gl-pt-3!"
label-for="cadence-title"
:invalid-feedback="i18n.requiredField"
:state="validationState.title"
>
<gl-form-input
id="cadence-title"
v-model="title"
autocomplete="off"
data-qa-selector="iteration_cadence_title_field"
:placeholder="i18n.title.placeholder"
size="xl"
:state="validationState.title"
@blur="validate('title')"
/>
</gl-form-group>
<gl-form-group
:label-cols-md="2"
label-class="gl-font-weight-bold text-right-md gl-pt-3!"
label-for="cadence-automated-scheduling"
:description="i18n.automatedScheduling.description"
>
<gl-form-checkbox
id="cadence-automated-scheduling"
v-model="automatic"
@change="clearValidation"
>
<span class="gl-font-weight-bold">{{ i18n.automatedScheduling.label }}</span>
</gl-form-checkbox>
</gl-form-group>
<gl-form-group
:label="i18n.startDate.label"
:label-cols-md="2"
label-class="text-right-md gl-pt-3!"
label-for="cadence-start-date"
:description="i18n.startDate.description"
:invalid-feedback="i18n.requiredField"
:state="validationState.startDate"
>
<gl-datepicker :target="null">
<gl-form-input
id="cadence-start-date"
v-model="startDate"
:placeholder="i18n.startDate.placeholder"
class="datepicker gl-datepicker-input"
autocomplete="off"
inputmode="none"
required
:state="validationState.startDate"
data-qa-selector="cadence_start_date"
@blur="validate('startDate')"
/>
</gl-datepicker>
</gl-form-group>
<gl-form-group
:label="i18n.duration.label"
:label-cols-md="2"
label-class="text-right-md gl-pt-3!"
label-for="cadence-duration"
:description="i18n.duration.description"
:invalid-feedback="i18n.requiredField"
:state="validationState.durationInWeeks"
>
<gl-form-select
id="cadence-duration"
v-model.number="durationInWeeks"
:options="$options.availableDurations"
class="gl-form-input-md"
required
data-qa-selector="iteration_cadence_name_field"
@change="validate('durationInWeeks')"
/>
</gl-form-group>
<gl-form-group
:label="i18n.futureIterations.label"
:label-cols-md="2"
:content-cols-md="2"
label-class="text-right-md gl-pt-3!"
label-for="cadence-schedule-future-iterations"
:description="i18n.futureIterations.description"
:invalid-feedback="i18n.requiredField"
:state="validationState.iterationsInAdvance"
>
<gl-form-select
id="cadence-schedule-future-iterations"
v-model.number="iterationsInAdvance"
:disabled="!automatic"
:options="$options.availableFutureIterations"
:required="automatic"
class="gl-form-input-md"
data-qa-selector="iteration_cadence_name_field"
@change="validate('iterationsInAdvance')"
/>
</gl-form-group>
<div class="form-actions gl-display-flex">
<gl-button
:loading="loading"
data-testid="save-cadence"
variant="confirm"
data-qa-selector="save_cadence_button"
@click="save"
>
{{ i18n.create }}
</gl-button>
<gl-button class="ml-auto" data-testid="cancel-create-cadence" @click="cancel">
{{ i18n.cancel }}
</gl-button>
</div>
</gl-form>
</article>
</template>
......@@ -98,17 +98,19 @@ export function initCadenceForm() {
return null;
}
const { groupFullPath: groupPath, cadenceId, cadenceListPath } = el;
const { groupFullPath: groupPath, cadenceId, cadencesListPath } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
groupPath,
},
render(createElement) {
return createElement(IterationCadenceForm, {
props: {
groupPath,
cadenceId,
cadenceListPath,
cadencesListPath,
},
});
},
......
mutation createIterationCadence($input: IterationCadenceCreateInput!) {
iterationCadenceCreate(input: $input) {
iterationCadence {
id
title
}
errors
}
}
import { GlFormCheckbox, GlFormGroup } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import IterationCadenceForm from 'ee/iterations/components/iteration_cadence_form.vue';
import createCadence from 'ee/iterations/queries/create_cadence.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { visitUrl } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility');
const localVue = createLocalVue();
function createMockApolloProvider(requestHandlers) {
localVue.use(VueApollo);
return createMockApollo(requestHandlers);
}
describe('Iteration cadence form', () => {
let wrapper;
const groupPath = 'gitlab-org';
const id = 72;
const iterationCadence = {
id: `gid://gitlab/Iteration/${id}`,
title: 'An iteration',
description: 'The words',
startDate: '2020-06-28',
dueDate: '2020-07-05',
};
const createMutationSuccess = {
data: { iterationCadenceCreate: { iterationCadence, errors: [] } },
};
const createMutationFailure = {
data: {
iterationCadenceCreate: { iterationCadence, errors: ['alas, your data is unchanged'] },
},
};
const defaultProps = { cadencesListPath: TEST_HOST };
function createComponent({ props = defaultProps, resolverMock } = {}) {
const apolloProvider = createMockApolloProvider([[createCadence, resolverMock]]);
wrapper = extendedWrapper(
mount(IterationCadenceForm, {
apolloProvider,
localVue,
propsData: props,
provide: {
groupPath,
},
}),
);
}
afterEach(() => {
wrapper.destroy();
});
const findTitleGroup = () => wrapper.findAllComponents(GlFormGroup).at(0);
const findAutomatedSchedulingGroup = () => wrapper.findAllComponents(GlFormGroup).at(1);
const findStartDateGroup = () => wrapper.findAllComponents(GlFormGroup).at(2);
const findDurationGroup = () => wrapper.findAllComponents(GlFormGroup).at(3);
const findFutureIterationsGroup = () => wrapper.findAllComponents(GlFormGroup).at(4);
const findTitle = () => wrapper.find('#cadence-title');
const findStartDate = () => wrapper.find('#cadence-start-date');
const findFutureIterations = () => wrapper.find('#cadence-schedule-future-iterations');
const findDuration = () => wrapper.find('#cadence-duration');
const findSaveButton = () => wrapper.findByTestId('save-cadence');
const findCancelButton = () => wrapper.findByTestId('cancel-create-cadence');
const clickSave = () => findSaveButton().vm.$emit('click');
const clickCancel = () => findCancelButton().vm.$emit('click');
describe('Create cadence', () => {
let resolverMock;
beforeEach(() => {
resolverMock = jest.fn().mockResolvedValue(createMutationSuccess);
createComponent({ resolverMock });
});
it('cancel button links to list page', () => {
clickCancel();
expect(visitUrl).toHaveBeenCalledWith(TEST_HOST);
});
describe('save', () => {
it('triggers mutation with form data', () => {
const title = 'Iteration 5';
const startDate = '2020-05-05';
const durationInWeeks = 2;
const iterationsInAdvance = 6;
findTitle().vm.$emit('input', title);
findStartDate().vm.$emit('input', startDate);
findDuration().vm.$emit('input', durationInWeeks);
findFutureIterations().vm.$emit('input', iterationsInAdvance);
clickSave();
expect(resolverMock).toHaveBeenCalledWith({
input: {
groupPath,
title,
automatic: true,
startDate,
durationInWeeks,
iterationsInAdvance,
active: true,
},
});
});
it('redirects to Iteration page on success', async () => {
const title = 'Iteration 5';
const startDate = '2020-05-05';
const durationInWeeks = 2;
const iterationsInAdvance = 6;
findTitle().vm.$emit('input', title);
findStartDate().vm.$emit('input', startDate);
findDuration().vm.$emit('input', durationInWeeks);
findFutureIterations().vm.$emit('input', iterationsInAdvance);
clickSave();
await waitForPromises();
expect(visitUrl).toHaveBeenCalled();
});
it('does not submit if required fields missing', () => {
clickSave();
expect(resolverMock).not.toHaveBeenCalled();
expect(findTitleGroup().text()).toContain('This field is required');
expect(findStartDateGroup().text()).toContain('This field is required');
expect(findDurationGroup().text()).toContain('This field is required');
expect(findFutureIterationsGroup().text()).toContain('This field is required');
});
it('loading=false on error', async () => {
resolverMock = jest.fn().mockResolvedValue(createMutationFailure);
createComponent({ resolverMock });
clickSave();
await waitForPromises();
expect(findSaveButton().props('loading')).toBe(false);
});
});
describe('automated scheduling disabled', () => {
beforeEach(() => {
findAutomatedSchedulingGroup().find(GlFormCheckbox).vm.$emit('input', false);
});
it('disables future iterations', () => {
expect(findFutureIterations().attributes('disabled')).toBe('disabled');
});
it('does not require future iterations ', () => {
const title = 'Iteration 5';
const startDate = '2020-05-05';
const durationInWeeks = 2;
findTitle().vm.$emit('input', title);
findStartDate().vm.$emit('input', startDate);
findDuration().vm.$emit('input', durationInWeeks);
clickSave();
expect(resolverMock).toHaveBeenCalledWith({
input: {
groupPath,
title,
automatic: false,
startDate,
durationInWeeks,
active: true,
},
});
});
});
});
});
......@@ -18231,6 +18231,51 @@ msgstr ""
msgid "Iterations"
msgstr ""
msgid "Iterations|Automated scheduling"
msgstr ""
msgid "Iterations|Cadence name"
msgstr ""
msgid "Iterations|Create cadence"
msgstr ""
msgid "Iterations|Duration"
msgstr ""
msgid "Iterations|Future iterations"
msgstr ""
msgid "Iterations|Iteration scheduling will be handled automatically"
msgstr ""
msgid "Iterations|New iteration cadence"
msgstr ""
msgid "Iterations|Number of future iterations you would like to have scheduled"
msgstr ""
msgid "Iterations|Select duration"
msgstr ""
msgid "Iterations|Select number"
msgstr ""
msgid "Iterations|Select start date"
msgstr ""
msgid "Iterations|Start date"
msgstr ""
msgid "Iterations|The duration for each iteration (in weeks)"
msgstr ""
msgid "Iterations|The start date of your first iteration"
msgstr ""
msgid "Iterations|Title"
msgstr ""
msgid "Iteration|Dates cannot overlap with other existing Iterations within this group"
msgstr ""
......@@ -34073,6 +34118,9 @@ msgstr ""
msgid "Unable to load the merge request widget. Try reloading the page."
msgstr ""
msgid "Unable to save cadence. Please try again"
msgstr ""
msgid "Unable to save iteration. Please try again"
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