Commit 940b4003 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Martin Wortschack

Added delete value stream UI

Adds a delete button for the
currently selected value stream

Ensure we can only delete custom value streams

Fix reminaing specs
parent 93c82a3a
......@@ -313,6 +313,19 @@ To create a value stream:
![New value stream](img/new_value_stream_v13_3.png "Creating a new value stream")
### Deleting a value stream
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/221205) in GitLab 13.4.
To delete a custom value stream:
1. Navigate to your group's **Analytics > Value Stream**.
1. Click the Value stream dropdown and select the value stream you would like to delete.
1. Click the **Delete (name of value stream)**.
1. Click the **Delete** button to confirm.
![Delete value stream](img/delete_value_stream_v13.4.png "Deleting a custom value stream")
### Disabling custom value streams
Custom value streams are enabled by default. If you have a self-managed instance, an
......
<script>
import {
GlAlert,
GlButton,
GlNewDropdown as GlDropdown,
GlNewDropdownItem as GlDropdownItem,
......@@ -33,6 +34,7 @@ const validate = ({ name }) => {
export default {
components: {
GlAlert,
GlButton,
GlDropdown,
GlDropdownItem,
......@@ -53,11 +55,16 @@ export default {
},
computed: {
...mapState({
isLoading: 'isCreatingValueStream',
isDeleting: 'isDeletingValueStream',
isCreating: 'isCreatingValueStream',
deleteValueStreamError: 'deleteValueStreamError',
initialFormErrors: 'createValueStreamErrors',
data: 'valueStreams',
selectedValueStream: 'selectedValueStream',
}),
isLoading() {
return this.isDeleting || this.isCreating;
},
isValid() {
return !this.errors.name?.length;
},
......@@ -73,10 +80,21 @@ export default {
selectedValueStreamId() {
return this.selectedValueStream?.id || null;
},
canDeleteSelectedStage() {
return this.selectedValueStream?.isCustom || false;
},
hasFormErrors() {
const { initialFormErrors } = this;
return Boolean(Object.keys(initialFormErrors).length);
},
deleteSelectedText() {
return sprintf(__('Delete %{name}'), { name: this.selectedValueStreamName });
},
deleteConfirmationText() {
return sprintf(__('Are you sure you want to delete "%{name}" Value Stream?'), {
name: this.selectedValueStreamName,
});
},
},
watch: {
initialFormErrors(newErrors = {}) {
......@@ -92,7 +110,7 @@ export default {
}
},
methods: {
...mapActions(['createValueStream', 'setSelectedValueStream']),
...mapActions(['createValueStream', 'setSelectedValueStream', 'deleteValueStream']),
onSubmit() {
const { name } = this;
return this.createValueStream({ name }).then(() => {
......@@ -114,6 +132,16 @@ export default {
onSelect(id) {
this.setSelectedValueStream(id);
},
onDelete() {
const name = this.selectedValueStreamName;
return this.deleteValueStream(this.selectedValueStreamId).then(() => {
if (!this.deleteValueStreamError) {
this.$toast.show(sprintf(__("'%{name}' Value Stream deleted"), { name }), {
position: 'top-center',
});
}
});
},
},
};
</script>
......@@ -137,12 +165,19 @@ export default {
<gl-dropdown-item v-gl-modal-directive="'create-value-stream-modal'" @click="onHandleInput">{{
__('Create new Value Stream')
}}</gl-dropdown-item>
<gl-dropdown-item
v-if="canDeleteSelectedStage"
v-gl-modal-directive="'delete-value-stream-modal'"
variant="danger"
data-testid="delete-value-stream"
>{{ deleteSelectedText }}</gl-dropdown-item
>
</gl-dropdown>
<gl-button v-else v-gl-modal-directive="'create-value-stream-modal'" @click="onHandleInput">{{
__('Create new Value Stream')
}}</gl-button>
<gl-modal
ref="modal"
data-testid="create-value-stream-modal"
modal-id="create-value-stream-modal"
:title="__('Value Stream Name')"
:action-primary="{
......@@ -175,5 +210,21 @@ export default {
/>
</gl-form-group>
</gl-modal>
<gl-modal
data-testid="delete-value-stream-modal"
modal-id="delete-value-stream-modal"
:title="__('Delete Value Stream')"
:action-primary="{
text: __('Delete'),
attributes: [{ variant: 'danger' }, { loading: isLoading }],
}"
:action-cancel="{ text: __('Cancel') }"
@primary.prevent="onDelete"
>
<gl-alert v-if="deleteValueStreamError" variant="danger">{{
deleteValueStreamError
}}</gl-alert>
<p>{{ deleteConfirmationText }}</p>
</gl-modal>
</gl-form>
</template>
---
title: Delete custom value streams
merge_request: 40927
author:
type: added
......@@ -874,27 +874,65 @@ RSpec.describe 'Group Value Stream Analytics', :js do
end
end
describe 'Create value stream', :js do
let(:custom_value_stream_name) { "Test value stream" }
let(:value_stream_dropdown) { page.find(value_stream_selector) }
def toggle_value_stream_dropdown
value_stream_dropdown.click
end
def select_value_stream(value_stream_name)
toggle_value_stream_dropdown
page.find('[data-testid="dropdown-value-streams"]').all('li button').find { |item| item.text == value_stream_name.to_s }.click
wait_for_requests
end
describe 'Multiple value streams', :js do
let(:value_stream_dropdown) { page.find(value_stream_selector) }
let!(:default_value_stream) { create(:cycle_analytics_group_value_stream, group: group, name: 'default') }
describe 'Create value stream' do
before do
select_group
wait_for_requests
end
it 'can create a value stream' do
custom_value_stream_name = "New created value stream"
toggle_value_stream_dropdown
page.find_button(_('Create new Value Stream')).click
fill_in 'create-value-stream-name', with: custom_value_stream_name
page.find_button(_('Create Value Stream')).click
wait_for_requests
expect(page).to have_text(_("'%{name}' Value Stream created") % { name: custom_value_stream_name })
end
end
describe 'Delete value stream' do
let(:custom_value_stream_name) { "Test value stream" }
before do
value_stream = create(:cycle_analytics_group_value_stream, name: custom_value_stream_name, group: group)
create(:cycle_analytics_group_stage, value_stream: value_stream)
select_group
wait_for_requests
end
it 'can delete a value stream' do
select_value_stream(custom_value_stream_name)
toggle_value_stream_dropdown
page.find_button(_('Delete %{name}') % { name: custom_value_stream_name }).click
page.find_button(_('Delete')).click
wait_for_requests
expect(page).to have_text(_("'%{name}' Value Stream deleted") % { name: custom_value_stream_name })
end
end
end
end
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlModal, GlNewDropdown as GlDropdown, GlFormGroup } from '@gitlab/ui';
import { GlButton, GlNewDropdown as GlDropdown, GlFormGroup } from '@gitlab/ui';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
import { valueStreams } from '../mock_data';
import { findDropdownItemText } from '../helpers';
......@@ -12,21 +12,28 @@ describe('ValueStreamSelect', () => {
let wrapper = null;
const createValueStreamMock = jest.fn(() => Promise.resolve());
const deleteValueStreamMock = jest.fn(() => Promise.resolve());
const mockEvent = { preventDefault: jest.fn() };
const mockToastShow = jest.fn();
const streamName = 'Cool stream';
const selectedValueStream = valueStreams[0];
const createValueStreamErrors = { name: ['Name field required'] };
const deleteValueStreamError = 'Cannot delete default value stream';
const fakeStore = ({ initialState = {} }) =>
new Vuex.Store({
state: {
isLoading: false,
isCreatingValueStream: false,
isDeletingValueStream: false,
createValueStreamErrors: {},
deleteValueStreamError: null,
valueStreams: [],
selectedValueStream: {},
...initialState,
},
actions: {
createValueStream: createValueStreamMock,
deleteValueStream: deleteValueStreamMock,
},
});
......@@ -46,12 +53,14 @@ describe('ValueStreamSelect', () => {
},
});
const findModal = () => wrapper.find(GlModal);
const submitButtonDisabledState = () => findModal().props('actionPrimary').attributes[1].disabled;
const submitForm = () => findModal().vm.$emit('primary', mockEvent);
const findModal = modal => wrapper.find(`[data-testid="${modal}-value-stream-modal"]`);
const createSubmitButtonDisabledState = () =>
findModal('create').props('actionPrimary').attributes[1].disabled;
const submitModal = modal => findModal(modal).vm.$emit('primary', mockEvent);
const findSelectValueStreamDropdown = () => wrapper.find(GlDropdown);
const findSelectValueStreamDropdownOptions = _wrapper => findDropdownItemText(_wrapper);
const findCreateValueStreamButton = () => wrapper.find(GlButton);
const findDeleteValueStreamButton = () => wrapper.find('[data-testid="delete-value-stream"]');
const findFormGroup = () => wrapper.find(GlFormGroup);
beforeEach(() => {
......@@ -81,6 +90,34 @@ describe('ValueStreamSelect', () => {
expect(opts).toContain(vs);
});
});
describe('with a selected value stream', () => {
it('renders a delete option for custom value streams', () => {
wrapper = createComponent({
initialState: {
valueStreams,
selectedValueStream: {
...selectedValueStream,
isCustom: true,
},
},
});
expect(findDeleteValueStreamButton().exists()).toBe(true);
expect(findDeleteValueStreamButton().text()).toBe(`Delete ${selectedValueStream.name}`);
});
it('does not render a delete option for default value streams', () => {
wrapper = createComponent({
initialState: {
valueStreams,
selectedValueStream,
},
});
expect(findDeleteValueStreamButton().exists()).toBe(false);
});
});
});
describe('Only the default value stream available', () => {
......@@ -121,29 +158,27 @@ describe('ValueStreamSelect', () => {
describe('Create value stream form', () => {
it('submit button is disabled', () => {
expect(submitButtonDisabledState()).toBe(true);
expect(createSubmitButtonDisabledState()).toBe(true);
});
describe('form errors', () => {
const fieldErrors = ['already exists', 'is required'];
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
initialState: {
createValueStreamErrors: {
name: fieldErrors,
},
createValueStreamErrors,
},
});
});
it('renders the error', () => {
expect(findFormGroup().attributes('invalid-feedback')).toEqual(fieldErrors.join('\n'));
expect(findFormGroup().attributes('invalid-feedback')).toEqual(
createValueStreamErrors.name.join('\n'),
);
});
it('submit button is disabled', () => {
expect(submitButtonDisabledState()).toBe(true);
expect(createSubmitButtonDisabledState()).toBe(true);
});
});
......@@ -153,12 +188,12 @@ describe('ValueStreamSelect', () => {
});
it('submit button is enabled', () => {
expect(submitButtonDisabledState()).toBe(false);
expect(createSubmitButtonDisabledState()).toBe(false);
});
describe('form submitted successfully', () => {
beforeEach(() => {
submitForm();
submitModal('create');
});
it('calls the "createValueStream" event when submitted', () => {
......@@ -179,15 +214,19 @@ describe('ValueStreamSelect', () => {
});
describe('form submission fails', () => {
const createValueStreamMockFail = jest.fn(() => Promise.reject());
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
actions: {
createValueStream: () => createValueStreamMockFail,
initialState: {
createValueStreamErrors,
},
});
submitModal('create');
});
it('calls the createValueStream action', () => {
expect(createValueStreamMock).toHaveBeenCalled();
});
it('does not clear the name field', () => {
......@@ -200,4 +239,51 @@ describe('ValueStreamSelect', () => {
});
});
});
describe('Delete value stream modal', () => {
describe('succeeds', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
valueStreams,
selectedValueStream: {
...selectedValueStream,
isCustom: true,
},
},
});
submitModal('delete');
});
it('calls the "deleteValueStream" event when submitted', () => {
expect(deleteValueStreamMock).toHaveBeenCalledWith(
expect.any(Object),
selectedValueStream.id,
);
});
it('displays a toast message', () => {
expect(mockToastShow).toHaveBeenCalledWith(
`'${selectedValueStream.name}' Value Stream deleted`,
{
position: 'top-center',
},
);
});
});
describe('fails', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
initialState: { deleteValueStreamError },
});
});
it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
});
});
});
......@@ -872,6 +872,9 @@ msgstr ""
msgid "'%{name}' Value Stream created"
msgstr ""
msgid "'%{name}' Value Stream deleted"
msgstr ""
msgid "'%{name}' stage already exists"
msgstr ""
......@@ -3250,6 +3253,9 @@ msgstr ""
msgid "Are you sure you want to close this blocked issue?"
msgstr ""
msgid "Are you sure you want to delete \"%{name}\" Value Stream?"
msgstr ""
msgid "Are you sure you want to delete %{name}?"
msgstr ""
......@@ -8086,12 +8092,18 @@ msgstr ""
msgid "Delete"
msgstr ""
msgid "Delete %{name}"
msgstr ""
msgid "Delete Comment"
msgstr ""
msgid "Delete Snippet"
msgstr ""
msgid "Delete Value Stream"
msgstr ""
msgid "Delete account"
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