Commit 679d69e6 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'psi-iteration-edit' into 'master'

Add form to edit iterations

See merge request gitlab-org/gitlab!34380
parents 89bff282 ee811215
...@@ -93,7 +93,7 @@ class Iteration < ApplicationRecord ...@@ -93,7 +93,7 @@ class Iteration < ApplicationRecord
# ensure dates do not overlap with other Iterations in the same group/project # ensure dates do not overlap with other Iterations in the same group/project
def dates_do_not_overlap def dates_do_not_overlap
return unless resource_parent.iterations.within_timeframe(start_date, due_date).exists? return unless resource_parent.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations")) errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations"))
end end
......
...@@ -7944,6 +7944,7 @@ type Mutation { ...@@ -7944,6 +7944,7 @@ type Mutation {
""" """
updateImageDiffNote(input: UpdateImageDiffNoteInput!): UpdateImageDiffNotePayload updateImageDiffNote(input: UpdateImageDiffNoteInput!): UpdateImageDiffNotePayload
updateIssue(input: UpdateIssueInput!): UpdateIssuePayload updateIssue(input: UpdateIssueInput!): UpdateIssuePayload
updateIteration(input: UpdateIterationInput!): UpdateIterationPayload
""" """
Updates a Note. If the body of the Note contains only quick actions, the Note Updates a Note. If the body of the Note contains only quick actions, the Note
...@@ -13257,6 +13258,66 @@ type UpdateIssuePayload { ...@@ -13257,6 +13258,66 @@ type UpdateIssuePayload {
issue: Issue issue: Issue
} }
"""
Autogenerated input type of UpdateIteration
"""
input UpdateIterationInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The description of the iteration
"""
description: String
"""
The end date of the iteration
"""
dueDate: String
"""
The group of the iteration
"""
groupPath: ID!
"""
The id of the iteration
"""
id: ID!
"""
The start date of the iteration
"""
startDate: String
"""
The title of the iteration
"""
title: String
}
"""
Autogenerated return type of UpdateIteration
"""
type UpdateIterationPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The updated iteration
"""
iteration: Iteration
}
""" """
Autogenerated input type of UpdateNote Autogenerated input type of UpdateNote
""" """
......
...@@ -23646,6 +23646,33 @@ ...@@ -23646,6 +23646,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "updateIteration",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "UpdateIterationInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "UpdateIterationPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "updateNote", "name": "updateNote",
"description": "Updates a Note. If the body of the Note contains only quick actions, the Note will be destroyed during the update, and no Note will be returned", "description": "Updates a Note. If the body of the Note contains only quick actions, the Note will be destroyed during the update, and no Note will be returned",
...@@ -39167,6 +39194,162 @@ ...@@ -39167,6 +39194,162 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INPUT_OBJECT",
"name": "UpdateIterationInput",
"description": "Autogenerated input type of UpdateIteration",
"fields": null,
"inputFields": [
{
"name": "groupPath",
"description": "The group of the iteration",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "id",
"description": "The id of the iteration",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "title",
"description": "The title of the iteration",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "description",
"description": "The description of the iteration",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "startDate",
"description": "The start date of the iteration",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "dueDate",
"description": "The end date of the iteration",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "UpdateIterationPayload",
"description": "Autogenerated return type of UpdateIteration",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iteration",
"description": "The updated iteration",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Iteration",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "INPUT_OBJECT", "kind": "INPUT_OBJECT",
"name": "UpdateNoteInput", "name": "UpdateNoteInput",
...@@ -2019,6 +2019,16 @@ Autogenerated return type of UpdateIssue ...@@ -2019,6 +2019,16 @@ Autogenerated return type of UpdateIssue
| `errors` | String! => Array | Errors encountered during execution of the mutation. | | `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue after mutation | | `issue` | Issue | The issue after mutation |
## UpdateIterationPayload
Autogenerated return type of UpdateIteration
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `iteration` | Iteration | The updated iteration |
## UpdateNotePayload ## UpdateNotePayload
Autogenerated return type of UpdateNote Autogenerated return type of UpdateNote
......
...@@ -38,7 +38,7 @@ From there you can create a new iteration or click an iteration to get a more de ...@@ -38,7 +38,7 @@ From there you can create a new iteration or click an iteration to get a more de
## Create an iteration ## Create an iteration
NOTE: **Note:** NOTE: **Note:**
A permission level of [Developer or higher](../../permissions.md) is required to create iterations. You need Developer [permissions](../../permissions.md) or higher to create an iteration.
To create an iteration: To create an iteration:
...@@ -47,7 +47,16 @@ To create an iteration: ...@@ -47,7 +47,16 @@ To create an iteration:
1. Enter the title, a description (optional), a start date, and a due date. 1. Enter the title, a description (optional), a start date, and a due date.
1. Click **Create iteration**. The iteration details page opens. 1. Click **Create iteration**. The iteration details page opens.
### Enable Iterations **(CORE ONLY)** ## Edit an iteration
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218277) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.2.
NOTE: **Note:**
You need Developer [permissions](../../permissions.md) or higher to edit an iteration.
To edit an iteration, click the three-dot menu (**{ellipsis_v}**) > **Edit iteration**.
## Enable Iterations **(CORE ONLY)**
GitLab Iterations feature is under development and not ready for production use. GitLab Iterations feature is under development and not ready for production use.
It is deployed behind a feature flag that is **disabled by default**. It is deployed behind a feature flag that is **disabled by default**.
......
...@@ -247,6 +247,7 @@ group. ...@@ -247,6 +247,7 @@ group.
| Create project in group | | | ✓ (3) | ✓ (3) | ✓ (3) | | Create project in group | | | ✓ (3) | ✓ (3) | ✓ (3) |
| Share (invite) groups with groups | | | | | ✓ | | Share (invite) groups with groups | | | | | ✓ |
| Create/edit/delete group milestones | | | ✓ | ✓ | ✓ | | Create/edit/delete group milestones | | | ✓ | ✓ | ✓ |
| Create/edit/delete iterations | | | ✓ | ✓ | ✓ |
| Enable/disable a dependency proxy **(PREMIUM)** | | | ✓ | ✓ | ✓ | | Enable/disable a dependency proxy **(PREMIUM)** | | | ✓ | ✓ | ✓ |
| Use security dashboard **(ULTIMATE)** | | | ✓ | ✓ | ✓ | | Use security dashboard **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
| Create/edit/delete metrics dashboard annotations | | | ✓ | ✓ | ✓ | | Create/edit/delete metrics dashboard annotations | | | ✓ | ✓ | ✓ |
......
...@@ -4,7 +4,9 @@ import createFlash from '~/flash'; ...@@ -4,7 +4,9 @@ import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createIteration from '../queries/create_iteration.mutation.graphql'; import createIteration from '../queries/create_iteration.mutation.graphql';
import updateIteration from '../queries/update_iteration.mutation.graphql';
import DueDateSelectors from '~/due_date_select'; import DueDateSelectors from '~/due_date_select';
export default { export default {
...@@ -26,19 +28,43 @@ export default { ...@@ -26,19 +28,43 @@ export default {
}, },
iterationsListPath: { iterationsListPath: {
type: String, type: String,
required: true, required: false,
default: '',
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
iteration: {
type: Object,
required: false,
default: () => ({}),
}, },
}, },
data() { data() {
return { return {
iterations: [], iterations: [],
loading: false, loading: false,
title: '', title: this.iteration.title,
description: '', description: this.iteration.description,
startDate: '', startDate: this.iteration.startDate,
dueDate: '', dueDate: this.iteration.dueDate,
}; };
}, },
computed: {
variables() {
return {
input: {
groupPath: this.groupPath,
title: this.title,
description: this.description,
startDate: this.startDate,
dueDate: this.dueDate,
},
};
},
},
mounted() { mounted() {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new DueDateSelectors(); new DueDateSelectors();
...@@ -46,22 +72,24 @@ export default { ...@@ -46,22 +72,24 @@ export default {
methods: { methods: {
save() { save() {
this.loading = true; this.loading = true;
return this.isEditing ? this.updateIteration() : this.createIteration();
},
cancel() {
if (this.iterationsListPath) {
visitUrl(this.iterationsListPath);
} else {
this.$emit('cancel');
}
},
createIteration() {
return this.$apollo return this.$apollo
.mutate({ .mutate({
mutation: createIteration, mutation: createIteration,
variables: { variables: this.variables,
input: {
groupPath: this.groupPath,
title: this.title,
description: this.description,
startDate: this.startDate,
dueDate: this.dueDate,
},
},
}) })
.then(({ data }) => { .then(({ data }) => {
const { errors, iteration } = data.createIteration; const { errors, iteration } = data.createIteration;
if (errors?.length > 0) { if (errors.length > 0) {
this.loading = false; this.loading = false;
createFlash(errors[0]); createFlash(errors[0]);
return; return;
...@@ -74,6 +102,33 @@ export default { ...@@ -74,6 +102,33 @@ export default {
createFlash(__('Unable to save iteration. Please try again')); createFlash(__('Unable to save iteration. Please try again'));
}); });
}, },
updateIteration() {
return this.$apollo
.mutate({
mutation: updateIteration,
variables: {
input: {
...this.variables.input,
id: getIdFromGraphQLId(this.iteration.id),
},
},
})
.then(({ data }) => {
const { errors } = data.updateIteration;
if (errors.length > 0) {
createFlash(errors[0]);
return;
}
this.$emit('updated');
})
.catch(() => {
createFlash(__('Unable to save iteration. Please try again'));
})
.finally(() => {
this.loading = false;
});
},
updateDueDate(val) { updateDueDate(val) {
this.dueDate = val; this.dueDate = val;
}, },
...@@ -87,7 +142,9 @@ export default { ...@@ -87,7 +142,9 @@ export default {
<template> <template>
<div> <div>
<div class="gl-display-flex"> <div class="gl-display-flex">
<h3 class="page-title">{{ __('New Iteration') }}</h3> <h3 ref="pageTitle" class="page-title">
{{ isEditing ? __('Edit iteration') : __('New iteration') }}
</h3>
</div> </div>
<hr /> <hr />
<gl-form class="row common-note-form"> <gl-form class="row common-note-form">
...@@ -174,9 +231,9 @@ export default { ...@@ -174,9 +231,9 @@ export default {
<div class="form-actions d-flex"> <div class="form-actions d-flex">
<gl-button :loading="loading" data-testid="save-iteration" variant="success" @click="save">{{ <gl-button :loading="loading" data-testid="save-iteration" variant="success" @click="save">{{
__('Create iteration') isEditing ? __('Update iteration') : __('Create iteration')
}}</gl-button> }}</gl-button>
<gl-button class="ml-auto" data-testid="cancel-iteration" :href="iterationsListPath">{{ <gl-button class="ml-auto" data-testid="cancel-iteration" @click="cancel">{{
__('Cancel') __('Cancel')
}}</gl-button> }}</gl-button>
</div> </div>
......
<script> <script>
import { GlAlert, GlBadge, GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; import {
GlAlert,
GlBadge,
GlLoadingIcon,
GlEmptyState,
GlIcon,
GlNewDropdown,
GlNewDropdownItem,
} from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import IterationForm from './iteration_form.vue';
import query from '../queries/group_iteration.query.graphql'; import query from '../queries/group_iteration.query.graphql';
const iterationStates = { const iterationStates = {
...@@ -17,6 +26,10 @@ export default { ...@@ -17,6 +26,10 @@ export default {
GlBadge, GlBadge,
GlLoadingIcon, GlLoadingIcon,
GlEmptyState, GlEmptyState,
GlIcon,
GlNewDropdown,
GlNewDropdownItem,
IterationForm,
}, },
apollo: { apollo: {
group: { group: {
...@@ -56,6 +69,7 @@ export default { ...@@ -56,6 +69,7 @@ export default {
}, },
data() { data() {
return { return {
isEditing: false,
error: '', error: '',
group: { group: {
iteration: {}, iteration: {},
...@@ -104,6 +118,14 @@ export default { ...@@ -104,6 +118,14 @@ export default {
:title="__('Could not find iteration')" :title="__('Could not find iteration')"
:compact="false" :compact="false"
/> />
<iteration-form
v-else-if="isEditing"
:group-path="groupPath"
:is-editing="true"
:iteration="iteration"
@updated="isEditing = false"
@cancel="isEditing = false"
/>
<template v-else> <template v-else>
<div <div
ref="topbar" ref="topbar"
...@@ -115,6 +137,21 @@ export default { ...@@ -115,6 +137,21 @@ export default {
<span class="gl-ml-4" <span class="gl-ml-4"
>{{ formatDate(iteration.startDate) }}{{ formatDate(iteration.dueDate) }}</span >{{ formatDate(iteration.startDate) }}{{ formatDate(iteration.dueDate) }}</span
> >
<gl-new-dropdown
v-if="canEdit"
variant="default"
toggle-class="gl-text-decoration-none gl-border-0! gl-shadow-none!"
class="gl-ml-auto gl-text-secondary"
right
no-caret
>
<template #button-content>
<gl-icon name="ellipsis_v" /><span class="gl-sr-only">{{ __('Actions') }}</span>
</template>
<gl-new-dropdown-item @click="isEditing = true">{{
__('Edit iteration')
}}</gl-new-dropdown-item>
</gl-new-dropdown>
</div> </div>
<h3 ref="title" class="page-title">{{ iteration.title }}</h3> <h3 ref="title" class="page-title">{{ iteration.title }}</h3>
<div ref="description" v-html="iteration.description"></div> <div ref="description" v-html="iteration.description"></div>
......
mutation updateIteration($input: UpdateIterationInput!) {
updateIteration(input: $input) {
iteration {
id
title
description
startDate
dueDate
}
errors
}
}
...@@ -14,6 +14,7 @@ module EE ...@@ -14,6 +14,7 @@ module EE
mount_mutation ::Mutations::Epics::SetSubscription mount_mutation ::Mutations::Epics::SetSubscription
mount_mutation ::Mutations::Epics::AddIssue mount_mutation ::Mutations::Epics::AddIssue
mount_mutation ::Mutations::Iterations::Create mount_mutation ::Mutations::Iterations::Create
mount_mutation ::Mutations::Iterations::Update
mount_mutation ::Mutations::RequirementsManagement::CreateRequirement mount_mutation ::Mutations::RequirementsManagement::CreateRequirement
mount_mutation ::Mutations::RequirementsManagement::UpdateRequirement mount_mutation ::Mutations::RequirementsManagement::UpdateRequirement
mount_mutation ::Mutations::Vulnerabilities::Dismiss mount_mutation ::Mutations::Vulnerabilities::Dismiss
......
# frozen_string_literal: true
module Mutations
module Iterations
class Update < BaseMutation
include Mutations::ResolvesGroup
include ResolvesProject
graphql_name 'UpdateIteration'
authorize :admin_iteration
field :iteration,
Types::IterationType,
null: true,
description: 'The updated iteration'
argument :group_path, GraphQL::ID_TYPE,
required: true,
description: "The group of the iteration"
argument :id,
GraphQL::ID_TYPE,
required: true,
description: 'The id of the iteration'
argument :title,
GraphQL::STRING_TYPE,
required: false,
description: 'The title of the iteration'
argument :description,
GraphQL::STRING_TYPE,
required: false,
description: 'The description of the iteration'
argument :start_date,
GraphQL::STRING_TYPE,
required: false,
description: 'The start date of the iteration'
argument :due_date,
GraphQL::STRING_TYPE,
required: false,
description: 'The end date of the iteration'
def resolve(args)
validate_arguments!(args)
parent = resolve_group(full_path: args[:group_path]).try(:sync)
iteration = authorized_find!(parent: parent, id: args[:id])
response = ::Iterations::UpdateService.new(parent, current_user, args).execute(iteration)
response_object = response.payload[:iteration] if response.success?
response_errors = response.error? ? (response.payload[:errors] || response.message) : []
{
iteration: response_object,
errors: response_errors
}
end
private
def find_object(parent:, id:)
::Resolvers::IterationsResolver.new(object: parent, context: context, field: nil).resolve(id: id).items.first
end
def validate_arguments!(args)
raise Gitlab::Graphql::Errors::ArgumentError, 'The list of iteration attributes is empty' if args.except(:group_path, :id).empty?
end
end
end
end
# frozen_string_literal: true
module Iterations
class UpdateService
include Gitlab::Allowable
IGNORED_KEYS = %i(group_path id state_enum state).freeze
attr_accessor :parent, :current_user, :params
def initialize(parent, user, params = {})
@parent, @current_user, @params = parent, user, params.dup
end
def execute(iteration)
return ::ServiceResponse.error(message: _('Operation not allowed'), http_status: 403) unless allowed?
iteration.assign_attributes(params.except(*IGNORED_KEYS))
if iteration.save
::ServiceResponse.success(message: _('Iteration updated'), payload: { iteration: iteration })
else
::ServiceResponse.error(message: _('Error creating new iteration'), payload: { errors: iteration.errors.full_messages })
end
end
private
def allowed?
parent.feature_available?(:iterations) && can?(current_user, :admin_iteration, parent)
end
end
end
...@@ -4,20 +4,35 @@ import { GlForm } from '@gitlab/ui'; ...@@ -4,20 +4,35 @@ import { GlForm } from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import IterationForm from 'ee/iterations/components/iteration_form.vue'; import IterationForm from 'ee/iterations/components/iteration_form.vue';
import createIteration from 'ee/iterations/queries/create_iteration.mutation.graphql'; import createIteration from 'ee/iterations/queries/create_iteration.mutation.graphql';
import updateIteration from 'ee/iterations/queries/update_iteration.mutation.graphql';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/lib/utils/url_utility'); jest.mock('~/lib/utils/url_utility');
describe('Iteration Form', () => { describe('Iteration Form', () => {
let wrapper; let wrapper;
const groupPath = 'gitlab-org'; const groupPath = 'gitlab-org';
const successfulMutation = { data: { createIteration: { iteration: {}, errors: [] } } }; const id = 72;
const failedMutation = { const iteration = {
data: { createIteration: { iteration: {}, errors: ['alas, your data is unchanged'] } }, id: `gid://gitlab/Iteration/${id}`,
title: 'An iteration',
description: 'The words',
startDate: '2020-06-28',
dueDate: '2020-07-05',
}; };
const props = { groupPath, iterationsListPath: TEST_HOST };
function createComponent({ mutationResult = successfulMutation } = {}) { const createMutationSuccess = { data: { createIteration: { iteration, errors: [] } } };
const createMutationFailure = {
data: { createIteration: { iteration, errors: ['alas, your data is unchanged'] } },
};
const updateMutationSuccess = { data: { updateIteration: { iteration, errors: [] } } };
const updateMutationFailure = {
data: { updateIteration: { iteration: {}, errors: ['alas, your data is unchanged'] } },
};
const defaultProps = { groupPath, iterationsListPath: TEST_HOST };
function createComponent({ mutationResult = createMutationSuccess, props = defaultProps } = {}) {
wrapper = shallowMount(IterationForm, { wrapper = shallowMount(IterationForm, {
propsData: props, propsData: props,
stubs: { stubs: {
...@@ -37,44 +52,141 @@ describe('Iteration Form', () => { ...@@ -37,44 +52,141 @@ describe('Iteration Form', () => {
wrapper = null; wrapper = null;
}); });
const findPageTitle = () => wrapper.find({ ref: 'pageTitle' });
const findTitle = () => wrapper.find('#iteration-title'); const findTitle = () => wrapper.find('#iteration-title');
const findDescription = () => wrapper.find('#iteration-description'); const findDescription = () => wrapper.find('#iteration-description');
const findStartDate = () => wrapper.find('#iteration-start-date'); const findStartDate = () => wrapper.find('#iteration-start-date');
const findDueDate = () => wrapper.find('#iteration-due-date'); const findDueDate = () => wrapper.find('#iteration-due-date');
const findSaveButton = () => wrapper.find('[data-testid="save-iteration"]'); const findSaveButton = () => wrapper.find('[data-testid="save-iteration"]');
const findCancelButton = () => wrapper.find('[data-testid="cancel-iteration"]'); const findCancelButton = () => wrapper.find('[data-testid="cancel-iteration"]');
const clickSave = () => findSaveButton().vm.$emit('click');
const clickCancel = () => findCancelButton().vm.$emit('click');
const nextTick = () => wrapper.vm.$nextTick();
it('renders a form', () => { it('renders a form', () => {
createComponent(); createComponent();
expect(wrapper.find(GlForm).exists()).toBe(true); expect(wrapper.find(GlForm).exists()).toBe(true);
}); });
it('cancel button links to list page', () => { describe('New iteration', () => {
createComponent(); beforeEach(() => {
expect(findCancelButton().attributes('href')).toBe(TEST_HOST); createComponent();
});
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 description = 'The fifth iteration';
const startDate = '2020-05-05';
const dueDate = '2020-05-25';
findTitle().vm.$emit('input', title);
findDescription().setValue(description);
findStartDate().vm.$emit('input', startDate);
findDueDate().vm.$emit('input', dueDate);
clickSave();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createIteration,
variables: {
input: {
groupPath,
title,
description,
startDate,
dueDate,
},
},
});
});
it('redirects to Iteration page on success', () => {
createComponent();
clickSave();
return nextTick().then(() => {
expect(findSaveButton().props('loading')).toBe(true);
expect(visitUrl).toHaveBeenCalled();
});
});
it('loading=false on error', () => {
createComponent({ mutationResult: createMutationFailure });
clickSave();
return waitForPromises().then(() => {
expect(findSaveButton().props('loading')).toBe(false);
});
});
});
}); });
describe('save', () => { describe('Edit iteration', () => {
it('trigges mutation with form data', () => { const propsWithIteration = {
createComponent(); groupPath,
isEditing: true,
iteration,
};
const title = 'Iteration 5'; it('shows update text title', () => {
const description = 'The fifth iteration'; createComponent({
const startDate = '2020-05-05'; props: propsWithIteration,
const dueDate = '2020-05-25'; });
expect(findPageTitle().text()).toBe('Edit iteration');
});
it('prefills form fields', () => {
createComponent({
props: propsWithIteration,
});
expect(findTitle().attributes('value')).toBe(iteration.title);
expect(findDescription().element.value).toBe(iteration.description);
expect(findStartDate().attributes('value')).toBe(iteration.startDate);
expect(findDueDate().attributes('value')).toBe(iteration.dueDate);
});
it('shows update text on submit button', () => {
createComponent({
props: propsWithIteration,
});
expect(findSaveButton().text()).toBe('Update iteration');
});
it('triggers mutation with form data', () => {
createComponent({
props: propsWithIteration,
});
const title = 'Updated title';
const description = 'Updated description';
const startDate = '2020-05-06';
const dueDate = '2020-05-26';
findTitle().vm.$emit('input', title); findTitle().vm.$emit('input', title);
findDescription().setValue(description); findDescription().setValue(description);
findStartDate().vm.$emit('input', startDate); findStartDate().vm.$emit('input', startDate);
findDueDate().vm.$emit('input', dueDate); findDueDate().vm.$emit('input', dueDate);
findSaveButton().vm.$emit('click'); clickSave();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createIteration, mutation: updateIteration,
variables: { variables: {
input: { input: {
groupPath, groupPath,
id,
title, title,
description, description,
startDate, startDate,
...@@ -84,28 +196,43 @@ describe('Iteration Form', () => { ...@@ -84,28 +196,43 @@ describe('Iteration Form', () => {
}); });
}); });
it('loading=true immediately', () => { it('emits updated event after successful mutation', () => {
createComponent(); createComponent({
props: propsWithIteration,
mutationResult: updateMutationSuccess,
});
wrapper.vm.save(); clickSave();
expect(wrapper.vm.loading).toBeTruthy(); return nextTick().then(() => {
expect(findSaveButton().props('loading')).toBe(true);
expect(wrapper.emitted('updated')).toHaveLength(1);
});
}); });
it('redirects to Iteration page on success', () => { it('emits updated event after failed mutation', () => {
createComponent(); createComponent({
props: propsWithIteration,
mutationResult: updateMutationFailure,
});
clickSave();
return wrapper.vm.save().then(() => { return nextTick().then(() => {
expect(findSaveButton().props('loading')).toBeTruthy(); expect(wrapper.emitted('updated')).toBeUndefined();
expect(visitUrl).toHaveBeenCalled();
}); });
}); });
it('loading=false on error', () => { it('emits cancel when cancel clicked', () => {
createComponent({ mutationResult: failedMutation }); createComponent({
props: propsWithIteration,
mutationResult: updateMutationSuccess,
});
clickCancel();
return wrapper.vm.save().then(() => { return nextTick().then(() => {
expect(findSaveButton().props('loading')).toBeFalsy(); expect(wrapper.emitted('cancel')).toHaveLength(1);
}); });
}); });
}); });
......
...@@ -30,9 +30,9 @@ RSpec.describe Resolvers::IterationsResolver do ...@@ -30,9 +30,9 @@ RSpec.describe Resolvers::IterationsResolver do
context 'without parameters' do context 'without parameters' do
it 'calls IterationsFinder to retrieve all iterations' do it 'calls IterationsFinder to retrieve all iterations' do
expect(IterationsFinder).to receive(:new) params = { id: nil, group_ids: group.id, state: 'all', start_date: nil, end_date: nil, search_title: nil }
.with(group_ids: group.id, state: 'all', start_date: nil, end_date: nil, search_title: nil, id: nil)
.and_call_original expect(IterationsFinder).to receive(:new).with(params).and_call_original
resolve_group_iterations resolve_group_iterations
end end
...@@ -44,10 +44,9 @@ RSpec.describe Resolvers::IterationsResolver do ...@@ -44,10 +44,9 @@ RSpec.describe Resolvers::IterationsResolver do
end_date = start_date + 1.hour end_date = start_date + 1.hour
search = 'wow' search = 'wow'
id = 1 id = 1
params = { id: id, group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date, search_title: search }
expect(IterationsFinder).to receive(:new) expect(IterationsFinder).to receive(:new).with(params).and_call_original
.with(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date, search_title: search, id: id)
.and_call_original
resolve_group_iterations(start_date: start_date, end_date: end_date, state: 'closed', title: search, id: id) resolve_group_iterations(start_date: start_date, end_date: end_date, state: 'closed', title: search, id: id)
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Updating an Iteration' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:iteration) { create(:iteration, group: group) }
let(:start_date) { 1.day.from_now.strftime('%F') }
let(:end_date) { 5.days.from_now.strftime('%F') }
let(:attributes) do
{
title: 'title',
description: 'some description',
start_date: start_date,
due_date: end_date
}
end
let(:mutation) do
params = { group_path: group.full_path, id: iteration.id }.merge(attributes)
graphql_mutation(:update_iteration, params)
end
def mutation_response
graphql_mutation_response(:update_iteration)
end
context 'when the user does not have permission' do
before do
stub_licensed_features(iterations: true)
group.add_guest(current_user)
end
it_behaves_like 'a mutation that returns top-level errors',
errors: ['The resource that you are attempting to access does not exist '\
'or you don\'t have permission to perform this action']
it 'does not update iteration' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(iteration, :title)
end
end
context 'when the user has permission' do
before do
group.add_developer(current_user)
end
context 'when iterations are disabled' do
before do
stub_licensed_features(iterations: false)
end
it_behaves_like 'a mutation that returns top-level errors',
errors: ['The resource that you are attempting to access does not '\
'exist or you don\'t have permission to perform this action']
end
context 'when iterations are enabled' do
before do
stub_licensed_features(iterations: true)
end
it 'updates the iteration', :aggregate_failures do
post_graphql_mutation(mutation, current_user: current_user)
# Let's check that the mutation response is good
iteration_hash = mutation_response['iteration']
expect(iteration_hash['title']).to eq('title')
expect(iteration_hash['description']).to eq('some description')
expect(iteration_hash['startDate'].to_date).to eq(start_date.to_date)
expect(iteration_hash['dueDate'].to_date).to eq(end_date.to_date)
# Let's also check that the object was updated properly
iteration.reload
expect(iteration.title).to eq('title')
expect(iteration.description).to eq('some description')
expect(iteration.start_date).to eq(start_date.to_date)
expect(iteration.due_date).to eq(end_date.to_date)
end
context 'when there are ActiveRecord validation errors' do
let(:attributes) { { start_date: 1.month.ago.strftime('%F') } }
it_behaves_like 'a mutation that returns errors in the response',
errors: ["Start date cannot be in the past"]
it 'does not update the iteration' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(iteration, :title)
end
end
context 'when the list of attributes is empty' do
let(:attributes) { {} }
it_behaves_like 'a mutation that returns top-level errors',
errors: ['The list of iteration attributes is empty']
it 'does not update the iteration' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(iteration, :title)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Iterations::UpdateService do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let_it_be(:iteration) { create(:iteration, group: group) }
describe '#execute' do
context "valid params" do
before do
group.add_maintainer(user)
end
subject { described_class.new(group, user, { title: 'new_title' }).execute(iteration) }
it { expect(subject).to be_success }
it { expect(subject.payload[:iteration].title).to eq('new_title') }
it 'ignores state change attempts' do
expect do
described_class.new(group, user, { state_enum: 'activate' }).execute(iteration)
end.not_to change { iteration.state_enum }
end
end
end
end
...@@ -1233,6 +1233,9 @@ msgstr "" ...@@ -1233,6 +1233,9 @@ msgstr ""
msgid "Action to take when receiving an alert." msgid "Action to take when receiving an alert."
msgstr "" msgstr ""
msgid "Actions"
msgstr ""
msgid "Activate" msgid "Activate"
msgstr "" msgstr ""
...@@ -8231,6 +8234,9 @@ msgstr "" ...@@ -8231,6 +8234,9 @@ msgstr ""
msgid "Edit issues" msgid "Edit issues"
msgstr "" msgstr ""
msgid "Edit iteration"
msgstr ""
msgid "Edit public deploy key" msgid "Edit public deploy key"
msgstr "" msgstr ""
...@@ -12668,6 +12674,9 @@ msgstr "" ...@@ -12668,6 +12674,9 @@ msgstr ""
msgid "Iteration removed" msgid "Iteration removed"
msgstr "" msgstr ""
msgid "Iteration updated"
msgstr ""
msgid "Iterations" msgid "Iterations"
msgstr "" msgstr ""
...@@ -14891,9 +14900,6 @@ msgid_plural "New Issues" ...@@ -14891,9 +14900,6 @@ msgid_plural "New Issues"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "New Iteration"
msgstr ""
msgid "New Jira import" msgid "New Jira import"
msgstr "" msgstr ""
...@@ -24521,6 +24527,9 @@ msgstr "" ...@@ -24521,6 +24527,9 @@ msgstr ""
msgid "Update it" msgid "Update it"
msgstr "" msgstr ""
msgid "Update iteration"
msgstr ""
msgid "Update now" msgid "Update now"
msgstr "" msgstr ""
......
...@@ -45,6 +45,14 @@ RSpec.describe Iteration do ...@@ -45,6 +45,14 @@ RSpec.describe Iteration do
it { is_expected.to be_valid } it { is_expected.to be_valid }
end end
context 'when updated iteration dates overlap with its own dates' do
it 'is valid' do
existing_iteration.start_date = 5.days.from_now
expect(existing_iteration).to be_valid
end
end
context 'when dates overlap' do context 'when dates overlap' do
context 'same group' do context 'same group' do
context 'when start_date is in range' do context 'when start_date is in range' do
......
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