Commit 3a7e132d authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '221205-delete-value-stream-mvc-fe' into 'master'

UI VSA delete value stream

See merge request gitlab-org/gitlab!40927
parents 884a1e61 940b4003
...@@ -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
...@@ -562,27 +562,65 @@ RSpec.describe 'Group Value Stream Analytics', :js do ...@@ -562,27 +562,65 @@ RSpec.describe 'Group Value Stream Analytics', :js do
end end
end end
describe 'Create value stream', :js do def toggle_value_stream_dropdown
let(:custom_value_stream_name) { "Test value stream" } 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(:value_stream_dropdown) { page.find(value_stream_selector) }
let!(:default_value_stream) { create(:cycle_analytics_group_value_stream, group: group, name: 'default') }
def toggle_value_stream_dropdown describe 'Create value stream' do
value_stream_dropdown.click before do
end select_group
before do wait_for_requests
select_group 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 end
it 'can create a value stream' do describe 'Delete value stream' do
toggle_value_stream_dropdown 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
page.find_button(_('Create new Value Stream')).click wait_for_requests
end
it 'can delete a value stream' do
select_value_stream(custom_value_stream_name)
fill_in 'create-value-stream-name', with: custom_value_stream_name toggle_value_stream_dropdown
page.find_button(_('Create Value Stream')).click
expect(page).to have_text(_("'%{name}' Value Stream created") % { name: custom_value_stream_name }) 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
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 ""
...@@ -8089,12 +8095,18 @@ msgstr "" ...@@ -8089,12 +8095,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