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:
- 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
......
......@@ -11,6 +11,7 @@ import {
GlSkeletonLoader,
} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { fetchPolicies } from '~/lib/graphql';
import { __, s__ } from '~/locale';
import { Namespace } from '../constants';
import groupQuery from '../queries/group_iterations_in_cadence.query.graphql';
......@@ -49,6 +50,7 @@ export default {
},
apollo: {
workspace: {
fetchPolicy: fetchPolicies.NETWORK_ONLY,
skip() {
return !this.expanded;
},
......
......@@ -6,6 +6,7 @@ import {
GlEmptyState,
GlIcon,
GlLoadingIcon,
GlModal,
} from '@gitlab/ui';
import BurnCharts from 'ee/burndown_chart/components/burn_charts.vue';
import { TYPE_ITERATION } from '~/graphql_shared/constants';
......@@ -14,6 +15,7 @@ import { formatDate } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { Namespace } from '../constants';
import deleteIteration from '../queries/destroy_iteration.mutation.graphql';
import query from '../queries/iteration.query.graphql';
import IterationReportTabs from './iteration_report_tabs.vue';
import TimeboxStatusBadge from './timebox_status_badge.vue';
......@@ -27,6 +29,7 @@ export default {
GlDropdownItem,
GlEmptyState,
GlLoadingIcon,
GlModal,
IterationReportTabs,
TimeboxStatusBadge,
},
......@@ -88,6 +91,31 @@ export default {
formatDate(date) {
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>
......@@ -114,6 +142,7 @@ export default {
>
<gl-dropdown
v-if="canEdit"
ref="menu"
data-testid="actions-dropdown"
variant="default"
toggle-class="gl-text-decoration-none gl-border-0! gl-shadow-none!"
......@@ -122,10 +151,29 @@ export default {
no-caret
>
<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>
<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-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>
<h3 ref="title" class="page-title">{{ iteration.title }}</h3>
<div
......
......@@ -7,14 +7,17 @@ import {
GlEmptyState,
GlIcon,
GlLoadingIcon,
GlModal,
} from '@gitlab/ui';
import BurnCharts from 'ee/burndown_chart/components/burn_charts.vue';
import { TYPE_ITERATION } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { formatDate } from '~/lib/utils/datetime_utility';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { Namespace } from '../constants';
import deleteIteration from '../queries/destroy_iteration.mutation.graphql';
import query from '../queries/iteration.query.graphql';
import IterationForm from './iteration_form_without_vue_router.vue';
import IterationReportTabs from './iteration_report_tabs.vue';
......@@ -42,6 +45,7 @@ export default {
GlLoadingIcon,
IterationForm,
IterationReportTabs,
GlModal,
},
apollo: {
iteration: {
......@@ -105,6 +109,11 @@ export default {
required: false,
default: '',
},
iterationsListPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -169,6 +178,32 @@ export default {
const newUrl = window.location.pathname.replace(/\/edit$/, '');
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>
......@@ -206,6 +241,7 @@ export default {
>
<gl-dropdown
v-if="canEditIteration"
ref="menu"
data-testid="actions-dropdown"
variant="default"
toggle-class="gl-text-decoration-none gl-border-0! gl-shadow-none!"
......@@ -217,7 +253,25 @@ export default {
<gl-icon name="ellipsis_v" /><span class="gl-sr-only">{{ __('Actions') }}</span>
</template>
<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-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>
<h3 ref="title" class="page-title">{{ iteration.title }}</h3>
<div
......
......@@ -66,6 +66,7 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) {
hasScopedLabelsFeature,
iterationId,
labelsFetchPath,
iterationsListPath,
editIterationPath,
previewMarkdownPath,
svgPath,
......@@ -90,6 +91,7 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) {
previewMarkdownPath,
svgPath,
initiallyEditing,
iterationsListPath,
},
});
},
......
mutation deleteIteration($id: IterationID!) {
iterationDelete(input: { id: $id }) {
errors
}
}
......@@ -4,7 +4,7 @@
- if Feature.enabled?(:group_iterations, @group, default_enabled: true)
.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,
iteration_id: params[:id],
preview_markdown_path: preview_markdown_path(@group) } }
......@@ -7,6 +7,7 @@
can_edit: can?(current_user, :admin_iteration, @group).to_s,
has_scoped_labels_feature: @group.licensed_feature_available?(:scoped_labels).to_s,
iteration_id: params[:id],
iterations_list_path: group_iterations_path(@group),
labels_fetch_path: group_labels_path(@group, format: :json, include_ancestor_groups: true),
preview_markdown_path: preview_markdown_path(@group),
svg_path: image_path('illustrations/issues.svg') } }
......@@ -7,5 +7,6 @@
has_scoped_labels_feature: @project.feature_available?(:scoped_labels).to_s,
iteration_id: params[:id],
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),
svg_path: image_path('illustrations/issues.svg') } }
......@@ -78,6 +78,23 @@ RSpec.describe 'User views iteration' do
let(:current_user) { user }
let(:shows_actions) { true }
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
context 'when user does not have edit permissions' do
......
......@@ -5,6 +5,7 @@ import IterationReport from 'ee/iterations/components/iteration_report.vue';
import IterationReportTabs from 'ee/iterations/components/iteration_report_tabs.vue';
import TimeboxStatusBadge from 'ee/iterations/components/timebox_status_badge.vue';
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 createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -13,6 +14,7 @@ import { mockIterationNode, mockGroupIterations, mockProjectIterations } from '.
const localVue = createLocalVue();
const $router = {
push: jest.fn(),
currentRoute: {
params: {
iterationId: String(getIdFromGraphQLId(mockIterationNode.id)),
......@@ -42,9 +44,14 @@ describe('Iterations report', () => {
props = defaultProps,
mockQueryResponse = mockGroupIterations,
iterationQueryHandler = jest.fn().mockResolvedValue(mockQueryResponse),
deleteMutationResponse = { data: { iterationDelete: { errors: [] } } },
deleteMutationMock = jest.fn().mockResolvedValue(deleteMutationResponse),
} = {}) => {
localVue.use(VueApollo);
mockApollo = createMockApollo([[query, iterationQueryHandler]]);
mockApollo = createMockApollo([
[query, iterationQueryHandler],
[deleteIteration, deleteMutationMock],
]);
wrapper = shallowMount(IterationReport, {
localVue,
......@@ -132,6 +139,60 @@ describe('Iterations report', () => {
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', () => {
it('shows empty state if no item loaded', async () => {
mountComponent({
......
......@@ -18871,6 +18871,9 @@ msgstr ""
msgid "Iterations|Delete iteration cadence?"
msgstr ""
msgid "Iterations|Delete iteration?"
msgstr ""
msgid "Iterations|Duration"
msgstr ""
......@@ -18940,6 +18943,9 @@ msgstr ""
msgid "Iterations|This will delete the cadence as well as all of the iterations within it."
msgstr ""
msgid "Iterations|This will remove the iteration from any issues that are assigned to it."
msgstr ""
msgid "Iterations|Title"
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