Commit 356482ce authored by Abdul Wadood's avatar Abdul Wadood Committed by Andrew Fontaine

Add "Delete" group button to the groups dashboard

Group owners can now delete a group and its subgroups from the
parent group level. Until now, group owners had to go into each
individual group to delete them, which was timely and inefficient.

Group owners can now view all groups and delete them from a single
place. The groups dashboard now shows a "Delete" group button in
the dropdown to the group owners.

Changelog: added
parent cc13a158
...@@ -41,6 +41,7 @@ export default { ...@@ -41,6 +41,7 @@ export default {
}, },
data() { data() {
return { return {
isModalVisible: false,
isLoading: true, isLoading: true,
isSearchEmpty: false, isSearchEmpty: false,
searchEmptyMessage: '', searchEmptyMessage: '',
...@@ -101,6 +102,12 @@ export default { ...@@ -101,6 +102,12 @@ export default {
eventHub.$off(`${this.action}updateGroups`, this.updateGroups); eventHub.$off(`${this.action}updateGroups`, this.updateGroups);
}, },
methods: { methods: {
hideModal() {
this.isModalVisible = false;
},
showModal() {
this.isModalVisible = true;
},
fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) { fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
return this.service return this.service
.getGroups(parentId, page, filterGroupsBy, sortBy, archived) .getGroups(parentId, page, filterGroupsBy, sortBy, archived)
...@@ -185,6 +192,7 @@ export default { ...@@ -185,6 +192,7 @@ export default {
showLeaveGroupModal(group, parentGroup) { showLeaveGroupModal(group, parentGroup) {
this.targetGroup = group; this.targetGroup = group;
this.targetParentGroup = parentGroup; this.targetParentGroup = parentGroup;
this.showModal();
}, },
leaveGroup() { leaveGroup() {
this.targetGroup.isBeingRemoved = true; this.targetGroup.isBeingRemoved = true;
...@@ -256,10 +264,12 @@ export default { ...@@ -256,10 +264,12 @@ export default {
/> />
<gl-modal <gl-modal
modal-id="leave-group-modal" modal-id="leave-group-modal"
:visible="isModalVisible"
:title="__('Are you sure?')" :title="__('Are you sure?')"
:action-primary="primaryProps" :action-primary="primaryProps"
:action-cancel="cancelProps" :action-cancel="cancelProps"
@primary="leaveGroup" @primary="leaveGroup"
@hide="hideModal"
> >
{{ groupLeaveConfirmationMessage }} {{ groupLeaveConfirmationMessage }}
</gl-modal> </gl-modal>
......
...@@ -34,8 +34,8 @@ export default { ...@@ -34,8 +34,8 @@ export default {
), ),
itemCaret, itemCaret,
itemTypeIcon, itemTypeIcon,
itemStats,
itemActions, itemActions,
itemStats,
}, },
props: { props: {
parentGroup: { parentGroup: {
...@@ -92,6 +92,9 @@ export default { ...@@ -92,6 +92,9 @@ export default {
complianceFramework() { complianceFramework() {
return this.group.complianceFramework; return this.group.complianceFramework;
}, },
showActionsMenu() {
return this.isGroup && (this.group.canEdit || this.group.canRemove || this.group.canLeave);
},
}, },
methods: { methods: {
onClickRowGroup(e) { onClickRowGroup(e) {
...@@ -197,17 +200,19 @@ export default { ...@@ -197,17 +200,19 @@ export default {
<div v-if="isGroupPendingRemoval"> <div v-if="isGroupPendingRemoval">
<gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge> <gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge>
</div> </div>
<div class="metadata d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between"> <div
class="metadata gl-display-flex gl-flex-grow-1 gl-flex-shrink-0 gl-flex-wrap justify-content-md-between"
>
<item-stats
:item="group"
class="group-stats gl-mt-2 gl-display-none gl-md-display-flex gl-align-items-center"
/>
<item-actions <item-actions
v-if="isGroup" v-if="showActionsMenu"
:group="group" :group="group"
:parent-group="parentGroup" :parent-group="parentGroup"
:action="action" :action="action"
/> />
<item-stats
:item="group"
class="group-stats gl-mt-2 d-none d-md-flex gl-align-items-center"
/>
</div> </div>
</div> </div>
</div> </div>
......
<script> <script>
import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui'; import { GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { COMMON_STR } from '../constants'; import { COMMON_STR } from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
const { LEAVE_BTN_TITLE, EDIT_BTN_TITLE, REMOVE_BTN_TITLE, OPTIONS_DROPDOWN_TITLE } = COMMON_STR;
export default { export default {
components: { components: {
GlButton, GlDropdown,
GlDropdownItem,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
}, },
props: { props: {
parentGroup: { parentGroup: {
...@@ -28,11 +30,8 @@ export default { ...@@ -28,11 +30,8 @@ export default {
}, },
}, },
computed: { computed: {
leaveBtnTitle() { removeButtonHref() {
return COMMON_STR.LEAVE_BTN_TITLE; return `${this.group.editPath}#js-remove-group-form`;
},
editBtnTitle() {
return COMMON_STR.EDIT_BTN_TITLE;
}, },
}, },
methods: { methods: {
...@@ -40,33 +39,51 @@ export default { ...@@ -40,33 +39,51 @@ export default {
eventHub.$emit(`${this.action}showLeaveGroupModal`, this.group, this.parentGroup); eventHub.$emit(`${this.action}showLeaveGroupModal`, this.group, this.parentGroup);
}, },
}, },
i18n: {
leaveBtnTitle: LEAVE_BTN_TITLE,
editBtnTitle: EDIT_BTN_TITLE,
removeBtnTitle: REMOVE_BTN_TITLE,
optionsDropdownTitle: OPTIONS_DROPDOWN_TITLE,
},
}; };
</script> </script>
<template> <template>
<div class="controls d-flex justify-content-end"> <div class="gl-display-flex gl-justify-content-end gl-ml-5">
<gl-button <gl-dropdown
v-if="group.canLeave" v-gl-tooltip.hover.focus="$options.i18n.optionsDropdownTitle"
v-gl-tooltip.top right
v-gl-modal.leave-group-modal category="tertiary"
:title="leaveBtnTitle" icon="ellipsis_v"
:aria-label="leaveBtnTitle" no-caret
data-testid="leave-group-btn" :data-testid="`group-${group.id}-dropdown-button`"
size="small" data-qa-selector="group_dropdown_button"
icon="leave" :data-qa-group-id="group.id"
class="leave-group gl-ml-3" >
@click.stop="onLeaveGroup" <gl-dropdown-item
/>
<gl-button
v-if="group.canEdit" v-if="group.canEdit"
v-gl-tooltip.top :data-testid="`edit-group-${group.id}-btn`"
:href="group.editPath" :href="group.editPath"
:title="editBtnTitle" @click.stop
:aria-label="editBtnTitle" >
data-testid="edit-group-btn" {{ $options.i18n.editBtnTitle }}
size="small" </gl-dropdown-item>
icon="pencil" <gl-dropdown-item
class="edit-group gl-ml-3" v-if="group.canLeave"
/> :data-testid="`leave-group-${group.id}-btn`"
@click.stop="onLeaveGroup"
>
{{ $options.i18n.leaveBtnTitle }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="group.canRemove"
:href="removeButtonHref"
:data-testid="`remove-group-${group.id}-btn`"
variant="danger"
@click.stop
>
{{ $options.i18n.removeBtnTitle }}
</gl-dropdown-item>
</gl-dropdown>
</div> </div>
</template> </template>
...@@ -15,8 +15,10 @@ export const COMMON_STR = { ...@@ -15,8 +15,10 @@ export const COMMON_STR = {
LEAVE_FORBIDDEN: s__( LEAVE_FORBIDDEN: s__(
'GroupsTree|Failed to leave the group. Please make sure you are not the only owner.', 'GroupsTree|Failed to leave the group. Please make sure you are not the only owner.',
), ),
LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'), LEAVE_BTN_TITLE: s__('GroupsTree|Leave group'),
EDIT_BTN_TITLE: s__('GroupsTree|Edit group'), EDIT_BTN_TITLE: s__('GroupsTree|Edit'),
REMOVE_BTN_TITLE: s__('GroupsTree|Delete'),
OPTIONS_DROPDOWN_TITLE: s__('GroupsTree|Options'),
GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'), GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'),
GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'), GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'),
}; };
......
...@@ -83,6 +83,7 @@ export default class GroupsStore { ...@@ -83,6 +83,7 @@ export default class GroupsStore {
leavePath: rawGroupItem.leave_path, leavePath: rawGroupItem.leave_path,
canEdit: rawGroupItem.can_edit, canEdit: rawGroupItem.can_edit,
canLeave: rawGroupItem.can_leave, canLeave: rawGroupItem.can_leave,
canRemove: rawGroupItem.can_remove,
type: rawGroupItem.type, type: rawGroupItem.type,
permission: rawGroupItem.permission, permission: rawGroupItem.permission,
children: groupChildren, children: groupChildren,
......
...@@ -58,6 +58,10 @@ class GroupChildEntity < Grape::Entity ...@@ -58,6 +58,10 @@ class GroupChildEntity < Grape::Entity
end end
end end
expose :can_remove, unless: lambda { |_instance, _options| project? } do |group|
can?(request.current_user, :admin_group, group)
end
expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance| expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
number_with_delimiter(instance.member_count) number_with_delimiter(instance.member_count)
end end
......
...@@ -422,10 +422,22 @@ for the group's projects to meet your group's needs. ...@@ -422,10 +422,22 @@ for the group's projects to meet your group's needs.
To remove a group and its contents: To remove a group and its contents:
1. Go to your group's **Settings > General** page. 1. On the top bar, select **Menu > Groups** and find your group.
1. Expand the **Path, transfer, remove** section. 1. On the left sidebar, select **Settings > General**.
1. Expand the **Advanced** section.
1. In the **Remove group** section, select **Remove group**.
1. Type the group name.
1. Select **Confirm**.
A group can also be removed from the groups dashboard:
1. On the top bar, select **Menu > Groups**.
1. Select **Your Groups**.
1. Select (**{ellipsis_v}**) for the group you want to delete.
1. Select **Delete**.
1. In the Remove group section, select **Remove group**. 1. In the Remove group section, select **Remove group**.
1. Confirm the action. 1. Type the group name.
1. Select **Confirm**.
This action removes the group. It also adds a background job to delete all projects in the group. This action removes the group. It also adds a background job to delete all projects in the group.
......
...@@ -17695,13 +17695,16 @@ msgstr "" ...@@ -17695,13 +17695,16 @@ msgstr ""
msgid "GroupsTree|Are you sure you want to leave the \"%{fullName}\" group?" msgid "GroupsTree|Are you sure you want to leave the \"%{fullName}\" group?"
msgstr "" msgstr ""
msgid "GroupsTree|Edit group" msgid "GroupsTree|Delete"
msgstr ""
msgid "GroupsTree|Edit"
msgstr "" msgstr ""
msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner." msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
msgstr "" msgstr ""
msgid "GroupsTree|Leave this group" msgid "GroupsTree|Leave group"
msgstr "" msgstr ""
msgid "GroupsTree|Loading groups" msgid "GroupsTree|Loading groups"
...@@ -17713,6 +17716,9 @@ msgstr "" ...@@ -17713,6 +17716,9 @@ msgstr ""
msgid "GroupsTree|No groups or projects matched your search" msgid "GroupsTree|No groups or projects matched your search"
msgstr "" msgstr ""
msgid "GroupsTree|Options"
msgstr ""
msgid "GroupsTree|Search by name" msgid "GroupsTree|Search by name"
msgstr "" msgstr ""
......
...@@ -15,6 +15,10 @@ RSpec.describe 'Dashboard Groups page', :js do ...@@ -15,6 +15,10 @@ RSpec.describe 'Dashboard Groups page', :js do
wait_for_requests wait_for_requests
end end
def click_options_menu(group)
page.find("[data-testid='group-#{group.id}-dropdown-button'").click
end
it 'shows groups user is member of' do it 'shows groups user is member of' do
group.add_owner(user) group.add_owner(user)
nested_group.add_owner(user) nested_group.add_owner(user)
...@@ -112,6 +116,67 @@ RSpec.describe 'Dashboard Groups page', :js do ...@@ -112,6 +116,67 @@ RSpec.describe 'Dashboard Groups page', :js do
end end
end end
context 'group actions dropdown' do
let!(:subgroup) { create(:group, :public, parent: group) }
context 'user with subgroup ownership' do
before do
subgroup.add_owner(user)
sign_in(user)
visit dashboard_groups_path
end
it 'cannot remove parent group' do
expect(page).not_to have_selector("[data-testid='group-#{group.id}-dropdown-button'")
end
end
context 'user with parent group ownership' do
before do
group.add_owner(user)
sign_in(user)
visit dashboard_groups_path
end
it 'can remove parent group' do
click_options_menu(group)
expect(page).to have_selector("[data-testid='remove-group-#{group.id}-btn']")
end
it 'can remove subgroups' do
click_group_caret(group)
click_options_menu(subgroup)
expect(page).to have_selector("[data-testid='remove-group-#{subgroup.id}-btn']")
end
end
context 'user is a maintainer' do
before do
group.add_maintainer(user)
sign_in(user)
visit dashboard_groups_path
click_options_menu(group)
end
it 'cannot remove the group' do
expect(page).not_to have_selector("[data-testid='remove-group-#{group.id}-btn']")
end
it 'cannot edit the group' do
expect(page).not_to have_selector("[data-testid='edit-group-#{group.id}-btn']")
end
it 'can leave the group' do
expect(page).to have_selector("[data-testid='leave-group-#{group.id}-btn']")
end
end
end
context 'when using pagination' do context 'when using pagination' do
let(:group) { create(:group, created_at: 5.days.ago) } let(:group) { create(:group, created_at: 5.days.ago) }
let(:group2) { create(:group, created_at: 2.days.ago) } let(:group2) { create(:group, created_at: 2.days.ago) }
......
...@@ -280,6 +280,7 @@ describe('AppComponent', () => { ...@@ -280,6 +280,7 @@ describe('AppComponent', () => {
expect(vm.targetParentGroup).toBe(null); expect(vm.targetParentGroup).toBe(null);
vm.showLeaveGroupModal(group, mockParentGroupItem); vm.showLeaveGroupModal(group, mockParentGroupItem);
expect(vm.isModalVisible).toBe(true);
expect(vm.targetGroup).not.toBe(null); expect(vm.targetGroup).not.toBe(null);
expect(vm.targetParentGroup).not.toBe(null); expect(vm.targetParentGroup).not.toBe(null);
}); });
...@@ -290,6 +291,7 @@ describe('AppComponent', () => { ...@@ -290,6 +291,7 @@ describe('AppComponent', () => {
expect(vm.groupLeaveConfirmationMessage).toBe(''); expect(vm.groupLeaveConfirmationMessage).toBe('');
vm.showLeaveGroupModal(group, mockParentGroupItem); vm.showLeaveGroupModal(group, mockParentGroupItem);
expect(vm.isModalVisible).toBe(true);
expect(vm.groupLeaveConfirmationMessage).toBe( expect(vm.groupLeaveConfirmationMessage).toBe(
`Are you sure you want to leave the "${group.fullName}" group?`, `Are you sure you want to leave the "${group.fullName}" group?`,
); );
......
import { shallowMount } from '@vue/test-utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ItemActions from '~/groups/components/item_actions.vue'; import ItemActions from '~/groups/components/item_actions.vue';
import eventHub from '~/groups/event_hub'; import eventHub from '~/groups/event_hub';
import { mockParentGroupItem, mockChildren } from '../mock_data'; import { mockParentGroupItem, mockChildren } from '../mock_data';
...@@ -13,7 +13,7 @@ describe('ItemActions', () => { ...@@ -13,7 +13,7 @@ describe('ItemActions', () => {
}; };
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMount(ItemActions, { wrapper = shallowMountExtended(ItemActions, {
propsData: { ...defaultProps, ...props }, propsData: { ...defaultProps, ...props },
}); });
}; };
...@@ -23,8 +23,10 @@ describe('ItemActions', () => { ...@@ -23,8 +23,10 @@ describe('ItemActions', () => {
wrapper = null; wrapper = null;
}); });
const findEditGroupBtn = () => wrapper.find('[data-testid="edit-group-btn"]'); const findEditGroupBtn = () => wrapper.findByTestId(`edit-group-${mockParentGroupItem.id}-btn`);
const findLeaveGroupBtn = () => wrapper.find('[data-testid="leave-group-btn"]'); const findLeaveGroupBtn = () => wrapper.findByTestId(`leave-group-${mockParentGroupItem.id}-btn`);
const findRemoveGroupBtn = () =>
wrapper.findByTestId(`remove-group-${mockParentGroupItem.id}-btn`);
describe('template', () => { describe('template', () => {
let group; let group;
...@@ -34,6 +36,7 @@ describe('ItemActions', () => { ...@@ -34,6 +36,7 @@ describe('ItemActions', () => {
...mockParentGroupItem, ...mockParentGroupItem,
canEdit: true, canEdit: true,
canLeave: true, canLeave: true,
canRemove: true,
}; };
createComponent({ group }); createComponent({ group });
}); });
...@@ -41,21 +44,21 @@ describe('ItemActions', () => { ...@@ -41,21 +44,21 @@ describe('ItemActions', () => {
it('renders component template correctly', () => { it('renders component template correctly', () => {
createComponent(); createComponent();
expect(wrapper.classes()).toContain('controls'); expect(wrapper.classes()).toContain('gl-display-flex', 'gl-justify-content-end', 'gl-ml-5');
}); });
it('renders "Edit group" button with correct attribute values', () => { it('renders "Edit" group button with correct attribute values', () => {
const button = findEditGroupBtn(); const button = findEditGroupBtn();
expect(button.exists()).toBe(true); expect(button.exists()).toBe(true);
expect(button.props('icon')).toBe('pencil'); expect(button.attributes('href')).toBe(mockParentGroupItem.editPath);
expect(button.attributes('aria-label')).toBe('Edit group');
}); });
it('renders "Leave this group" button with correct attribute values', () => { it('renders "Delete" group button with correct attribute values', () => {
const button = findLeaveGroupBtn(); const button = findRemoveGroupBtn();
expect(button.exists()).toBe(true); expect(button.exists()).toBe(true);
expect(button.props('icon')).toBe('leave'); expect(button.attributes('href')).toBe(
expect(button.attributes('aria-label')).toBe('Leave this group'); `${mockParentGroupItem.editPath}#js-remove-group-form`,
);
}); });
it('emits `showLeaveGroupModal` event in the event hub', () => { it('emits `showLeaveGroupModal` event in the event hub', () => {
...@@ -103,4 +106,15 @@ describe('ItemActions', () => { ...@@ -103,4 +106,15 @@ describe('ItemActions', () => {
expect(findEditGroupBtn().exists()).toBe(false); expect(findEditGroupBtn().exists()).toBe(false);
}); });
it('does not render delete button if group can not be edited', () => {
createComponent({
group: {
...mockParentGroupItem,
canRemove: false,
},
});
expect(findRemoveGroupBtn().exists()).toBe(false);
});
}); });
...@@ -6,7 +6,8 @@ RSpec.describe GroupChildEntity do ...@@ -6,7 +6,8 @@ RSpec.describe GroupChildEntity do
include ExternalAuthorizationServiceHelpers include ExternalAuthorizationServiceHelpers
include Gitlab::Routing.url_helpers include Gitlab::Routing.url_helpers
let(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:request) { double('request') } let(:request) { double('request') }
let(:entity) { described_class.new(object, request: request) } let(:entity) { described_class.new(object, request: request) }
...@@ -103,6 +104,22 @@ RSpec.describe GroupChildEntity do ...@@ -103,6 +104,22 @@ RSpec.describe GroupChildEntity do
expect(json[:can_leave]).to be_truthy expect(json[:can_leave]).to be_truthy
end end
it 'allows an owner to delete the group' do
expect(json[:can_remove]).to be_truthy
end
it 'allows admin to delete the group', :enable_admin_mode do
allow(request).to receive(:current_user).and_return(create(:admin))
expect(json[:can_remove]).to be_truthy
end
it 'disallows a maintainer to delete the group' do
object.add_maintainer(user)
expect(json[:can_remove]).to be_falsy
end
it 'has the correct edit path' do it 'has the correct edit path' do
expect(json[:edit_path]).to eq(edit_group_path(object)) expect(json[:edit_path]).to eq(edit_group_path(object))
end end
......
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