Commit 73af7e1d authored by Kushal Pandya's avatar Kushal Pandya

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

Add button to delete iteration

See merge request gitlab-org/gitlab!65621
parents 84c68071 79060310
...@@ -110,7 +110,17 @@ Prerequisites: ...@@ -110,7 +110,17 @@ Prerequisites:
- You must have at least the [Developer role](../../permissions.md) for a group. - You must have at least the [Developer role](../../permissions.md) for a group.
To edit an iteration, select the three-dot menu (**{ellipsis_v}**) > **Edit iteration**. To edit an iteration, select the three-dot menu (**{ellipsis_v}**) > **Edit**.
## Delete an iteration
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/292268) in GitLab 14.3.
Prerequisites:
- You must have at least the [Developer role](../../permissions.md) for a group.
To delete an iteration, select the three-dot menu (**{ellipsis_v}**) > **Delete**.
## Add an issue to an iteration ## Add an issue to an iteration
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
GlSkeletonLoader, GlSkeletonLoader,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { fetchPolicies } from '~/lib/graphql';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { Namespace } from '../constants'; import { Namespace } from '../constants';
import groupQuery from '../queries/group_iterations_in_cadence.query.graphql'; import groupQuery from '../queries/group_iterations_in_cadence.query.graphql';
...@@ -49,6 +50,7 @@ export default { ...@@ -49,6 +50,7 @@ export default {
}, },
apollo: { apollo: {
workspace: { workspace: {
fetchPolicy: fetchPolicies.NETWORK_ONLY,
skip() { skip() {
return !this.expanded; return !this.expanded;
}, },
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
GlEmptyState, GlEmptyState,
GlIcon, GlIcon,
GlLoadingIcon, GlLoadingIcon,
GlModal,
} from '@gitlab/ui'; } from '@gitlab/ui';
import BurnCharts from 'ee/burndown_chart/components/burn_charts.vue'; import BurnCharts from 'ee/burndown_chart/components/burn_charts.vue';
import { TYPE_ITERATION } from '~/graphql_shared/constants'; import { TYPE_ITERATION } from '~/graphql_shared/constants';
...@@ -14,6 +15,7 @@ import { formatDate } from '~/lib/utils/datetime_utility'; ...@@ -14,6 +15,7 @@ import { formatDate } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { Namespace } from '../constants'; import { Namespace } from '../constants';
import deleteIteration from '../queries/destroy_iteration.mutation.graphql';
import query from '../queries/iteration.query.graphql'; import query from '../queries/iteration.query.graphql';
import IterationReportTabs from './iteration_report_tabs.vue'; import IterationReportTabs from './iteration_report_tabs.vue';
import TimeboxStatusBadge from './timebox_status_badge.vue'; import TimeboxStatusBadge from './timebox_status_badge.vue';
...@@ -27,6 +29,7 @@ export default { ...@@ -27,6 +29,7 @@ export default {
GlDropdownItem, GlDropdownItem,
GlEmptyState, GlEmptyState,
GlLoadingIcon, GlLoadingIcon,
GlModal,
IterationReportTabs, IterationReportTabs,
TimeboxStatusBadge, TimeboxStatusBadge,
}, },
...@@ -88,6 +91,31 @@ export default { ...@@ -88,6 +91,31 @@ export default {
formatDate(date) { formatDate(date) {
return formatDate(date, 'mmm d, yyyy', true); return formatDate(date, 'mmm d, yyyy', true);
}, },
showModal() {
this.$refs.modal.show();
},
focusMenu() {
this.$refs.menu.$el.focus();
},
deleteIteration() {
this.$apollo
.mutate({
mutation: deleteIteration,
variables: {
id: this.iteration.id,
},
})
.then(({ data: { iterationDelete } }) => {
if (iterationDelete.errors?.length) {
throw iterationDelete.errors[0];
}
this.$router.push('/');
})
.catch((err) => {
this.error = err;
});
},
}, },
}; };
</script> </script>
...@@ -114,6 +142,7 @@ export default { ...@@ -114,6 +142,7 @@ export default {
> >
<gl-dropdown <gl-dropdown
v-if="canEdit" v-if="canEdit"
ref="menu"
data-testid="actions-dropdown" data-testid="actions-dropdown"
variant="default" variant="default"
toggle-class="gl-text-decoration-none gl-border-0! gl-shadow-none!" toggle-class="gl-text-decoration-none gl-border-0! gl-shadow-none!"
...@@ -122,10 +151,29 @@ export default { ...@@ -122,10 +151,29 @@ export default {
no-caret no-caret
> >
<template #button-content> <template #button-content>
<gl-icon name="ellipsis_v" /><span class="gl-sr-only">{{ __('Actions') }}</span> <span class="gl-sr-only">{{ __('Actions') }}</span
><gl-icon name="ellipsis_v" />
</template> </template>
<gl-dropdown-item :to="editPage">{{ __('Edit') }}</gl-dropdown-item> <gl-dropdown-item :to="editPage">{{ __('Edit') }}</gl-dropdown-item>
<gl-dropdown-item data-testid="delete-iteration" @click="showModal">
{{ __('Delete') }}
</gl-dropdown-item>
</gl-dropdown> </gl-dropdown>
<gl-modal
ref="modal"
:modal-id="`${iteration.id}-delete-modal`"
:title="s__('Iterations|Delete iteration?')"
:ok-title="__('Delete')"
ok-variant="danger"
@hidden="focusMenu"
@ok="deleteIteration"
>
{{
s__(
'Iterations|This will remove the iteration from any issues that are assigned to it.',
)
}}
</gl-modal>
</div> </div>
<h3 ref="title" class="page-title">{{ iteration.title }}</h3> <h3 ref="title" class="page-title">{{ iteration.title }}</h3>
<div <div
......
...@@ -7,14 +7,17 @@ import { ...@@ -7,14 +7,17 @@ import {
GlEmptyState, GlEmptyState,
GlIcon, GlIcon,
GlLoadingIcon, GlLoadingIcon,
GlModal,
} from '@gitlab/ui'; } from '@gitlab/ui';
import BurnCharts from 'ee/burndown_chart/components/burn_charts.vue'; import BurnCharts from 'ee/burndown_chart/components/burn_charts.vue';
import { TYPE_ITERATION } from '~/graphql_shared/constants'; import { TYPE_ITERATION } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId } from '~/graphql_shared/utils';
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { Namespace } from '../constants'; import { Namespace } from '../constants';
import deleteIteration from '../queries/destroy_iteration.mutation.graphql';
import query from '../queries/iteration.query.graphql'; import query from '../queries/iteration.query.graphql';
import IterationForm from './iteration_form_without_vue_router.vue'; import IterationForm from './iteration_form_without_vue_router.vue';
import IterationReportTabs from './iteration_report_tabs.vue'; import IterationReportTabs from './iteration_report_tabs.vue';
...@@ -42,6 +45,7 @@ export default { ...@@ -42,6 +45,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
IterationForm, IterationForm,
IterationReportTabs, IterationReportTabs,
GlModal,
}, },
apollo: { apollo: {
iteration: { iteration: {
...@@ -105,6 +109,11 @@ export default { ...@@ -105,6 +109,11 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
iterationsListPath: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
...@@ -169,6 +178,32 @@ export default { ...@@ -169,6 +178,32 @@ export default {
const newUrl = window.location.pathname.replace(/\/edit$/, ''); const newUrl = window.location.pathname.replace(/\/edit$/, '');
window.history.pushState({ prev: page.edit }, null, newUrl); window.history.pushState({ prev: page.edit }, null, newUrl);
}, },
showModal() {
this.$refs.modal.show();
},
focusMenu() {
this.$refs.menu.$el.focus();
},
deleteIteration() {
this.$apollo
.mutate({
mutation: deleteIteration,
variables: {
id: convertToGraphQLId(TYPE_ITERATION, this.iterationId),
},
})
.then(({ data: { iterationDelete } }) => {
if (iterationDelete.errors?.length) {
throw iterationDelete.errors[0];
}
this.isEditing = false;
visitUrl(this.iterationsListPath);
})
.catch((err) => {
this.error = err;
});
},
}, },
}; };
</script> </script>
...@@ -206,6 +241,7 @@ export default { ...@@ -206,6 +241,7 @@ export default {
> >
<gl-dropdown <gl-dropdown
v-if="canEditIteration" v-if="canEditIteration"
ref="menu"
data-testid="actions-dropdown" data-testid="actions-dropdown"
variant="default" variant="default"
toggle-class="gl-text-decoration-none gl-border-0! gl-shadow-none!" toggle-class="gl-text-decoration-none gl-border-0! gl-shadow-none!"
...@@ -217,7 +253,25 @@ export default { ...@@ -217,7 +253,25 @@ export default {
<gl-icon name="ellipsis_v" /><span class="gl-sr-only">{{ __('Actions') }}</span> <gl-icon name="ellipsis_v" /><span class="gl-sr-only">{{ __('Actions') }}</span>
</template> </template>
<gl-dropdown-item @click="loadEditPage">{{ __('Edit') }}</gl-dropdown-item> <gl-dropdown-item @click="loadEditPage">{{ __('Edit') }}</gl-dropdown-item>
<gl-dropdown-item data-testid="delete-iteration" @click="showModal">
{{ __('Delete') }}
</gl-dropdown-item>
</gl-dropdown> </gl-dropdown>
<gl-modal
ref="modal"
:modal-id="`${iterationId}-delete-modal`"
:title="s__('Iterations|Delete iteration?')"
:ok-title="__('Delete')"
ok-variant="danger"
@hidden="focusMenu"
@ok="deleteIteration"
>
{{
s__(
'Iterations|This will remove the iteration from any issues that are assigned to it.',
)
}}
</gl-modal>
</div> </div>
<h3 ref="title" class="page-title">{{ iteration.title }}</h3> <h3 ref="title" class="page-title">{{ iteration.title }}</h3>
<div <div
......
...@@ -66,6 +66,7 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) { ...@@ -66,6 +66,7 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) {
hasScopedLabelsFeature, hasScopedLabelsFeature,
iterationId, iterationId,
labelsFetchPath, labelsFetchPath,
iterationsListPath,
editIterationPath, editIterationPath,
previewMarkdownPath, previewMarkdownPath,
svgPath, svgPath,
...@@ -90,6 +91,7 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) { ...@@ -90,6 +91,7 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) {
previewMarkdownPath, previewMarkdownPath,
svgPath, svgPath,
initiallyEditing, initiallyEditing,
iterationsListPath,
}, },
}); });
}, },
......
mutation deleteIteration($id: IterationID!) {
iterationDelete(input: { id: $id }) {
errors
}
}
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
- if Feature.enabled?(:group_iterations, @group, default_enabled: true) - if Feature.enabled?(:group_iterations, @group, default_enabled: true)
.js-iteration{ data: { full_path: @group.full_path, .js-iteration{ data: { full_path: @group.full_path,
iterations_list_path: group_iterations_path(@group),
can_edit: can?(current_user, :admin_iteration, @group).to_s, can_edit: can?(current_user, :admin_iteration, @group).to_s,
iteration_id: params[:id], iteration_id: params[:id],
preview_markdown_path: preview_markdown_path(@group) } } preview_markdown_path: preview_markdown_path(@group) } }
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
can_edit: can?(current_user, :admin_iteration, @group).to_s, can_edit: can?(current_user, :admin_iteration, @group).to_s,
has_scoped_labels_feature: @group.licensed_feature_available?(:scoped_labels).to_s, has_scoped_labels_feature: @group.licensed_feature_available?(:scoped_labels).to_s,
iteration_id: params[:id], iteration_id: params[:id],
iterations_list_path: group_iterations_path(@group),
labels_fetch_path: group_labels_path(@group, format: :json, include_ancestor_groups: true), labels_fetch_path: group_labels_path(@group, format: :json, include_ancestor_groups: true),
preview_markdown_path: preview_markdown_path(@group), preview_markdown_path: preview_markdown_path(@group),
svg_path: image_path('illustrations/issues.svg') } } svg_path: image_path('illustrations/issues.svg') } }
...@@ -7,5 +7,6 @@ ...@@ -7,5 +7,6 @@
has_scoped_labels_feature: @project.feature_available?(:scoped_labels).to_s, has_scoped_labels_feature: @project.feature_available?(:scoped_labels).to_s,
iteration_id: params[:id], iteration_id: params[:id],
labels_fetch_path: project_labels_path(@project, format: :json, include_ancestor_groups: true), labels_fetch_path: project_labels_path(@project, format: :json, include_ancestor_groups: true),
iterations_list_path: project_iterations_path(@project),
preview_markdown_path: preview_markdown_path(@project), preview_markdown_path: preview_markdown_path(@project),
svg_path: image_path('illustrations/issues.svg') } } svg_path: image_path('illustrations/issues.svg') } }
...@@ -78,6 +78,23 @@ RSpec.describe 'User views iteration' do ...@@ -78,6 +78,23 @@ RSpec.describe 'User views iteration' do
let(:current_user) { user } let(:current_user) { user }
let(:shows_actions) { true } let(:shows_actions) { true }
end end
it 'can delete iteration' do
sign_in(user)
visit group_iteration_path(iteration.group, iteration.id)
click_button 'Actions'
click_button 'Delete'
page.within '.gl-modal' do
click_button 'Delete'
end
wait_for_requests
expect(page).to have_content('No iterations to show')
expect(page).not_to have_content(iteration.title)
end
end end
context 'when user does not have edit permissions' do context 'when user does not have edit permissions' do
......
...@@ -5,6 +5,7 @@ import IterationReport from 'ee/iterations/components/iteration_report.vue'; ...@@ -5,6 +5,7 @@ import IterationReport from 'ee/iterations/components/iteration_report.vue';
import IterationReportTabs from 'ee/iterations/components/iteration_report_tabs.vue'; import IterationReportTabs from 'ee/iterations/components/iteration_report_tabs.vue';
import TimeboxStatusBadge from 'ee/iterations/components/timebox_status_badge.vue'; import TimeboxStatusBadge from 'ee/iterations/components/timebox_status_badge.vue';
import { Namespace } from 'ee/iterations/constants'; import { Namespace } from 'ee/iterations/constants';
import deleteIteration from 'ee/iterations/queries/destroy_iteration.mutation.graphql';
import query from 'ee/iterations/queries/iteration.query.graphql'; import query from 'ee/iterations/queries/iteration.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
...@@ -13,6 +14,7 @@ import { mockIterationNode, mockGroupIterations, mockProjectIterations } from '. ...@@ -13,6 +14,7 @@ import { mockIterationNode, mockGroupIterations, mockProjectIterations } from '.
const localVue = createLocalVue(); const localVue = createLocalVue();
const $router = { const $router = {
push: jest.fn(),
currentRoute: { currentRoute: {
params: { params: {
iterationId: String(getIdFromGraphQLId(mockIterationNode.id)), iterationId: String(getIdFromGraphQLId(mockIterationNode.id)),
...@@ -42,9 +44,14 @@ describe('Iterations report', () => { ...@@ -42,9 +44,14 @@ describe('Iterations report', () => {
props = defaultProps, props = defaultProps,
mockQueryResponse = mockGroupIterations, mockQueryResponse = mockGroupIterations,
iterationQueryHandler = jest.fn().mockResolvedValue(mockQueryResponse), iterationQueryHandler = jest.fn().mockResolvedValue(mockQueryResponse),
deleteMutationResponse = { data: { iterationDelete: { errors: [] } } },
deleteMutationMock = jest.fn().mockResolvedValue(deleteMutationResponse),
} = {}) => { } = {}) => {
localVue.use(VueApollo); localVue.use(VueApollo);
mockApollo = createMockApollo([[query, iterationQueryHandler]]); mockApollo = createMockApollo([
[query, iterationQueryHandler],
[deleteIteration, deleteMutationMock],
]);
wrapper = shallowMount(IterationReport, { wrapper = shallowMount(IterationReport, {
localVue, localVue,
...@@ -132,6 +139,60 @@ describe('Iterations report', () => { ...@@ -132,6 +139,60 @@ describe('Iterations report', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('delete iteration', () => {
it('deletes iteration', async () => {
mountComponent();
wrapper.vm.deleteIteration();
await waitForPromises();
expect($router.push).toHaveBeenCalledWith('/');
});
it('shows error when delete fails', async () => {
mountComponent({
deleteMutationResponse: {
data: {
iterationDelete: {
errors: [
"upcoming/current iterations can't be deleted unless they are the last one in the cadence",
],
__typename: 'IterationDeletePayload',
},
},
},
});
wrapper.vm.deleteIteration();
await waitForPromises();
expect($router.push).not.toHaveBeenCalled();
});
it('shows error when delete rejects', async () => {
mountComponent({
deleteMutationMock: jest.fn().mockRejectedValue({
data: {
iterationDelete: {
errors: [
"upcoming/current iterations can't be deleted unless they are the last one in the cadence",
],
__typename: 'IterationDeletePayload',
},
},
}),
});
wrapper.vm.deleteIteration();
await waitForPromises();
expect($router.push).not.toHaveBeenCalled();
});
});
describe('empty state', () => { describe('empty state', () => {
it('shows empty state if no item loaded', async () => { it('shows empty state if no item loaded', async () => {
mountComponent({ mountComponent({
......
...@@ -18871,6 +18871,9 @@ msgstr "" ...@@ -18871,6 +18871,9 @@ msgstr ""
msgid "Iterations|Delete iteration cadence?" msgid "Iterations|Delete iteration cadence?"
msgstr "" msgstr ""
msgid "Iterations|Delete iteration?"
msgstr ""
msgid "Iterations|Duration" msgid "Iterations|Duration"
msgstr "" msgstr ""
...@@ -18940,6 +18943,9 @@ msgstr "" ...@@ -18940,6 +18943,9 @@ msgstr ""
msgid "Iterations|This will delete the cadence as well as all of the iterations within it." msgid "Iterations|This will delete the cadence as well as all of the iterations within it."
msgstr "" msgstr ""
msgid "Iterations|This will remove the iteration from any issues that are assigned to it."
msgstr ""
msgid "Iterations|Title" msgid "Iterations|Title"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment