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