Commit ee811215 authored by Simon Knox's avatar Simon Knox Committed by Natalia Tepluhina

Add basic iteration report view

Filtering iterations by ID, and display just info
No edit view for now
parent 61903a35
......@@ -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,44 +52,141 @@ 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', () => {
createComponent();
expect(findCancelButton().attributes('href')).toBe(TEST_HOST);
describe('New iteration', () => {
beforeEach(() => {
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', () => {
it('trigges mutation with form data', () => {
createComponent();
describe('Edit iteration', () => {
const propsWithIteration = {
groupPath,
isEditing: true,
iteration,
};
const title = 'Iteration 5';
const description = 'The fifth iteration';
const startDate = '2020-05-05';
const dueDate = '2020-05-25';
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);
findSaveButton().vm.$emit('click');
clickSave();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createIteration,
mutation: updateIteration,
variables: {
input: {
groupPath,
id,
title,
description,
startDate,
......@@ -84,28 +196,43 @@ describe('Iteration Form', () => {
});
});
it('loading=true immediately', () => {
createComponent();
it('emits updated event after successful mutation', () => {
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', () => {
createComponent();
it('emits updated event after failed mutation', () => {
createComponent({
props: propsWithIteration,
mutationResult: updateMutationFailure,
});
clickSave();
return wrapper.vm.save().then(() => {
expect(findSaveButton().props('loading')).toBeTruthy();
expect(visitUrl).toHaveBeenCalled();
return nextTick().then(() => {
expect(wrapper.emitted('updated')).toBeUndefined();
});
});
it('loading=false on error', () => {
createComponent({ mutationResult: failedMutation });
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 ""
......@@ -8234,6 +8237,9 @@ msgstr ""
msgid "Edit issues"
msgstr ""
msgid "Edit iteration"
msgstr ""
msgid "Edit public deploy key"
msgstr ""
......@@ -12665,6 +12671,9 @@ msgstr ""
msgid "Iteration removed"
msgstr ""
msgid "Iteration updated"
msgstr ""
msgid "Iterations"
msgstr ""
......@@ -14888,9 +14897,6 @@ msgid_plural "New Issues"
msgstr[0] ""
msgstr[1] ""
msgid "New Iteration"
msgstr ""
msgid "New Jira import"
msgstr ""
......@@ -24518,6 +24524,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