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
# ensure dates do not overlap with other Iterations in the same group/project
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"))
end
......
......@@ -7944,6 +7944,7 @@ type Mutation {
"""
updateImageDiffNote(input: UpdateImageDiffNoteInput!): UpdateImageDiffNotePayload
updateIssue(input: UpdateIssueInput!): UpdateIssuePayload
updateIteration(input: UpdateIterationInput!): UpdateIterationPayload
"""
Updates a Note. If the body of the Note contains only quick actions, the Note
......@@ -13257,6 +13258,66 @@ type UpdateIssuePayload {
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
"""
......
......@@ -23646,6 +23646,33 @@
"isDeprecated": false,
"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",
"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 @@
"enumValues": 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",
"name": "UpdateNoteInput",
......@@ -2019,6 +2019,16 @@ Autogenerated return type of UpdateIssue
| `errors` | String! => Array | Errors encountered during execution of the 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
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
## Create an iteration
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:
......@@ -47,7 +47,16 @@ To create an iteration:
1. Enter the title, a description (optional), a start date, and a due date.
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.
It is deployed behind a feature flag that is **disabled by default**.
......
......@@ -247,6 +247,7 @@ group.
| Create project in group | | | ✓ (3) | ✓ (3) | ✓ (3) |
| Share (invite) groups with groups | | | | | ✓ |
| Create/edit/delete group milestones | | | ✓ | ✓ | ✓ |
| Create/edit/delete iterations | | | ✓ | ✓ | ✓ |
| Enable/disable a dependency proxy **(PREMIUM)** | | | ✓ | ✓ | ✓ |
| Use security dashboard **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
| Create/edit/delete metrics dashboard annotations | | | ✓ | ✓ | ✓ |
......
......@@ -4,7 +4,9 @@ import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createIteration from '../queries/create_iteration.mutation.graphql';
import updateIteration from '../queries/update_iteration.mutation.graphql';
import DueDateSelectors from '~/due_date_select';
export default {
......@@ -26,19 +28,43 @@ export default {
},
iterationsListPath: {
type: String,
required: true,
required: false,
default: '',
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
iteration: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
iterations: [],
loading: false,
title: '',
description: '',
startDate: '',
dueDate: '',
title: this.iteration.title,
description: this.iteration.description,
startDate: this.iteration.startDate,
dueDate: this.iteration.dueDate,
};
},
computed: {
variables() {
return {
input: {
groupPath: this.groupPath,
title: this.title,
description: this.description,
startDate: this.startDate,
dueDate: this.dueDate,
},
};
},
},
mounted() {
// eslint-disable-next-line no-new
new DueDateSelectors();
......@@ -46,22 +72,24 @@ export default {
methods: {
save() {
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
.mutate({
mutation: createIteration,
variables: {
input: {
groupPath: this.groupPath,
title: this.title,
description: this.description,
startDate: this.startDate,
dueDate: this.dueDate,
},
},
variables: this.variables,
})
.then(({ data }) => {
const { errors, iteration } = data.createIteration;
if (errors?.length > 0) {
if (errors.length > 0) {
this.loading = false;
createFlash(errors[0]);
return;
......@@ -74,6 +102,33 @@ export default {
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) {
this.dueDate = val;
},
......@@ -87,7 +142,9 @@ export default {
<template>
<div>
<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>
<hr />
<gl-form class="row common-note-form">
......@@ -174,9 +231,9 @@ export default {
<div class="form-actions d-flex">
<gl-button :loading="loading" data-testid="save-iteration" variant="success" @click="save">{{
__('Create iteration')
isEditing ? __('Update iteration') : __('Create iteration')
}}</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')
}}</gl-button>
</div>
......
<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 { __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import IterationForm from './iteration_form.vue';
import query from '../queries/group_iteration.query.graphql';
const iterationStates = {
......@@ -17,6 +26,10 @@ export default {
GlBadge,
GlLoadingIcon,
GlEmptyState,
GlIcon,
GlNewDropdown,
GlNewDropdownItem,
IterationForm,
},
apollo: {
group: {
......@@ -56,6 +69,7 @@ export default {
},
data() {
return {
isEditing: false,
error: '',
group: {
iteration: {},
......@@ -104,6 +118,14 @@ export default {
:title="__('Could not find iteration')"
: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>
<div
ref="topbar"
......@@ -115,6 +137,21 @@ export default {
<span class="gl-ml-4"
>{{ 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>
<h3 ref="title" class="page-title">{{ iteration.title }}</h3>
<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
mount_mutation ::Mutations::Epics::SetSubscription
mount_mutation ::Mutations::Epics::AddIssue
mount_mutation ::Mutations::Iterations::Create
mount_mutation ::Mutations::Iterations::Update
mount_mutation ::Mutations::RequirementsManagement::CreateRequirement
mount_mutation ::Mutations::RequirementsManagement::UpdateRequirement
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';
import { visitUrl } from '~/lib/utils/url_utility';
import IterationForm from 'ee/iterations/components/iteration_form.vue';
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 waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/lib/utils/url_utility');
describe('Iteration Form', () => {
let wrapper;
const groupPath = 'gitlab-org';
const successfulMutation = { data: { createIteration: { iteration: {}, errors: [] } } };
const failedMutation = {
data: { createIteration: { iteration: {}, errors: ['alas, your data is unchanged'] } },
const id = 72;
const iteration = {
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, {
propsData: props,
stubs: {
......@@ -37,27 +52,35 @@ describe('Iteration Form', () => {
wrapper = null;
});
const findPageTitle = () => wrapper.find({ ref: 'pageTitle' });
const findTitle = () => wrapper.find('#iteration-title');
const findDescription = () => wrapper.find('#iteration-description');
const findStartDate = () => wrapper.find('#iteration-start-date');
const findDueDate = () => wrapper.find('#iteration-due-date');
const findSaveButton = () => wrapper.find('[data-testid="save-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', () => {
createComponent();
expect(wrapper.find(GlForm).exists()).toBe(true);
});
it('cancel button links to list page', () => {
describe('New iteration', () => {
beforeEach(() => {
createComponent();
expect(findCancelButton().attributes('href')).toBe(TEST_HOST);
});
describe('save', () => {
it('trigges mutation with form data', () => {
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';
......@@ -68,7 +91,7 @@ describe('Iteration Form', () => {
findStartDate().vm.$emit('input', startDate);
findDueDate().vm.$emit('input', dueDate);
findSaveButton().vm.$emit('click');
clickSave();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createIteration,
......@@ -84,28 +107,132 @@ describe('Iteration Form', () => {
});
});
it('loading=true immediately', () => {
it('redirects to Iteration page on success', () => {
createComponent();
wrapper.vm.save();
clickSave();
expect(wrapper.vm.loading).toBeTruthy();
return nextTick().then(() => {
expect(findSaveButton().props('loading')).toBe(true);
expect(visitUrl).toHaveBeenCalled();
});
});
it('redirects to Iteration page on success', () => {
createComponent();
it('loading=false on error', () => {
createComponent({ mutationResult: createMutationFailure });
return wrapper.vm.save().then(() => {
expect(findSaveButton().props('loading')).toBeTruthy();
expect(visitUrl).toHaveBeenCalled();
clickSave();
return waitForPromises().then(() => {
expect(findSaveButton().props('loading')).toBe(false);
});
});
});
});
it('loading=false on error', () => {
createComponent({ mutationResult: failedMutation });
describe('Edit iteration', () => {
const propsWithIteration = {
groupPath,
isEditing: true,
iteration,
};
it('shows update text title', () => {
createComponent({
props: propsWithIteration,
});
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);
findDescription().setValue(description);
findStartDate().vm.$emit('input', startDate);
findDueDate().vm.$emit('input', dueDate);
clickSave();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateIteration,
variables: {
input: {
groupPath,
id,
title,
description,
startDate,
dueDate,
},
},
});
});
it('emits updated event after successful mutation', () => {
createComponent({
props: propsWithIteration,
mutationResult: updateMutationSuccess,
});
clickSave();
return nextTick().then(() => {
expect(findSaveButton().props('loading')).toBe(true);
expect(wrapper.emitted('updated')).toHaveLength(1);
});
});
it('emits updated event after failed mutation', () => {
createComponent({
props: propsWithIteration,
mutationResult: updateMutationFailure,
});
clickSave();
return nextTick().then(() => {
expect(wrapper.emitted('updated')).toBeUndefined();
});
});
it('emits cancel when cancel clicked', () => {
createComponent({
props: propsWithIteration,
mutationResult: updateMutationSuccess,
});
clickCancel();
return wrapper.vm.save().then(() => {
expect(findSaveButton().props('loading')).toBeFalsy();
return nextTick().then(() => {
expect(wrapper.emitted('cancel')).toHaveLength(1);
});
});
});
......
......@@ -30,9 +30,9 @@ RSpec.describe Resolvers::IterationsResolver do
context 'without parameters' do
it 'calls IterationsFinder to retrieve all iterations' do
expect(IterationsFinder).to receive(:new)
.with(group_ids: group.id, state: 'all', start_date: nil, end_date: nil, search_title: nil, id: nil)
.and_call_original
params = { id: nil, group_ids: group.id, state: 'all', start_date: nil, end_date: nil, search_title: nil }
expect(IterationsFinder).to receive(:new).with(params).and_call_original
resolve_group_iterations
end
......@@ -44,10 +44,9 @@ RSpec.describe Resolvers::IterationsResolver do
end_date = start_date + 1.hour
search = 'wow'
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)
.with(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date, search_title: search, id: id)
.and_call_original
expect(IterationsFinder).to receive(:new).with(params).and_call_original
resolve_group_iterations(start_date: start_date, end_date: end_date, state: 'closed', title: search, id: id)
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 ""
msgid "Action to take when receiving an alert."
msgstr ""
msgid "Actions"
msgstr ""
msgid "Activate"
msgstr ""
......@@ -8231,6 +8234,9 @@ msgstr ""
msgid "Edit issues"
msgstr ""
msgid "Edit iteration"
msgstr ""
msgid "Edit public deploy key"
msgstr ""
......@@ -12668,6 +12674,9 @@ msgstr ""
msgid "Iteration removed"
msgstr ""
msgid "Iteration updated"
msgstr ""
msgid "Iterations"
msgstr ""
......@@ -14891,9 +14900,6 @@ msgid_plural "New Issues"
msgstr[0] ""
msgstr[1] ""
msgid "New Iteration"
msgstr ""
msgid "New Jira import"
msgstr ""
......@@ -24521,6 +24527,9 @@ msgstr ""
msgid "Update it"
msgstr ""
msgid "Update iteration"
msgstr ""
msgid "Update now"
msgstr ""
......
......@@ -45,6 +45,14 @@ RSpec.describe Iteration do
it { is_expected.to be_valid }
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 'same group' 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