Skip to content
Snippets Groups Projects
Verified Commit dcca5426 authored by Zack Cuddy's avatar Zack Cuddy :two:
Browse files

Organizations - Delete groups from list

This change adds logic to support
deleting groups from an organization
list view.
parent ffe25f9b
No related branches found
No related tags found
1 merge request!146250Organizations - Delete groups from list
Showing
with 294 additions and 39 deletions
......@@ -48,6 +48,12 @@ export function updateGroup(groupId, data = {}) {
return axios.put(url, data);
}
export function deleteGroup(groupId) {
const url = buildApiUrl(GROUP_PATH).replace(':id', groupId);
return axios.delete(url);
}
export const getGroupTransferLocations = (groupId, params = {}) => {
const url = buildApiUrl(GROUP_TRANSFER_LOCATIONS_PATH).replace(':id', groupId);
const defaultParams = { per_page: DEFAULT_PER_PAGE };
......
......@@ -57,6 +57,9 @@ export const organizationGroups = [
projectsCount: 3,
groupMembersCount: 2,
visibility: 'public',
userPermissions: {
removeGroup: true,
},
maxAccessLevel: {
integerValue: 30,
},
......@@ -73,6 +76,9 @@ export const organizationGroups = [
projectsCount: 3,
groupMembersCount: 1,
visibility: 'private',
userPermissions: {
removeGroup: true,
},
maxAccessLevel: {
integerValue: 30,
},
......@@ -89,6 +95,9 @@ export const organizationGroups = [
projectsCount: 1,
groupMembersCount: 2,
visibility: 'internal',
userPermissions: {
removeGroup: true,
},
maxAccessLevel: {
integerValue: 30,
},
......@@ -105,6 +114,9 @@ export const organizationGroups = [
projectsCount: 2,
groupMembersCount: 3,
visibility: 'public',
userPermissions: {
removeGroup: true,
},
maxAccessLevel: {
integerValue: 30,
},
......@@ -120,6 +132,9 @@ export const organizationGroups = [
projectsCount: 3,
groupMembersCount: 10,
visibility: 'private',
userPermissions: {
removeGroup: true,
},
maxAccessLevel: {
integerValue: 30,
},
......@@ -136,6 +151,9 @@ export const organizationGroups = [
projectsCount: 3,
groupMembersCount: 40,
visibility: 'internal',
userPermissions: {
removeGroup: true,
},
maxAccessLevel: {
integerValue: 30,
},
......@@ -152,6 +170,9 @@ export const organizationGroups = [
projectsCount: 30,
groupMembersCount: 100,
visibility: 'public',
userPermissions: {
removeGroup: true,
},
maxAccessLevel: {
integerValue: 30,
},
......@@ -167,6 +188,9 @@ export const organizationGroups = [
projectsCount: 1,
groupMembersCount: 1,
visibility: 'private',
userPermissions: {
removeGroup: true,
},
maxAccessLevel: {
integerValue: 30,
},
......@@ -174,9 +198,7 @@ export const organizationGroups = [
{
id: 'gid://gitlab/Group/74',
fullName: 'Twitter / test subgroup',
parent: {
id: 'gid://gitlab/Group/35',
},
parent: null,
webUrl: 'http://127.0.0.1:3000/groups/twitter/test-subgroup',
descriptionHtml: '',
avatarUrl: null,
......@@ -184,6 +206,9 @@ export const organizationGroups = [
projectsCount: 4,
groupMembersCount: 4,
visibility: 'internal',
userPermissions: {
removeGroup: false,
},
maxAccessLevel: {
integerValue: 30,
},
......
......@@ -3,7 +3,9 @@ import { GlLoadingIcon, GlEmptyState, GlKeysetPagination } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { s__, __ } from '~/locale';
import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue';
import { ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
import { DEFAULT_PER_PAGE } from '~/api';
import { deleteGroup } from '~/rest_api';
import groupsQuery from '../graphql/queries/groups.query.graphql';
import { SORT_ITEM_NAME, SORT_DIRECTION_ASC } from '../constants';
import { formatGroups } from '../utils';
......@@ -14,6 +16,9 @@ export default {
errorMessage: s__(
'Organization|An error occurred loading the groups. Please refresh the page to try again.',
),
deleteErrorMessage: s__(
'Organization|An error occurred deleting the group. Please refresh the page to try again.',
),
emptyState: {
title: s__("Organization|You don't have any groups yet."),
description: s__(
......@@ -155,6 +160,22 @@ export default {
startCursor,
});
},
setGroupIsDeleting(nodeIndex, value) {
this.groups.nodes[nodeIndex].actionLoadingStates[ACTION_DELETE] = value;
},
async deleteGroup(group) {
const nodeIndex = this.groups.nodes.findIndex((node) => node.id === group.id);
try {
this.setGroupIsDeleting(nodeIndex, true);
await deleteGroup(group.id);
this.$apollo.queries.groups.refetch();
} catch (error) {
createAlert({ message: this.$options.i18n.deleteErrorMessage, error, captureError: true });
} finally {
this.setGroupIsDeleting(nodeIndex, false);
}
},
},
};
</script>
......@@ -162,7 +183,12 @@ export default {
<template>
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" />
<div v-else-if="nodes.length">
<groups-list :groups="nodes" show-group-icon :list-item-class="listItemClass" />
<groups-list
:groups="nodes"
show-group-icon
:list-item-class="listItemClass"
@delete="deleteGroup"
/>
<div v-if="pageInfo.hasNextPage || pageInfo.hasPreviousPage" class="gl-text-center gl-mt-5">
<gl-keyset-pagination
......
......@@ -31,6 +31,9 @@ query getOrganizationGroups(
projectsCount
groupMembersCount
visibility
userPermissions {
removeGroup
}
maxAccessLevel {
integerValue
}
......
......@@ -12,6 +12,16 @@ const availableProjectActions = (userPermissions) => {
return baseActions;
};
const availableGroupActions = (userPermissions) => {
const baseActions = [ACTION_EDIT];
if (userPermissions.removeGroup) {
return [...baseActions, ACTION_DELETE];
}
return baseActions;
};
export const formatProjects = (projects) =>
projects.map(
({
......@@ -43,14 +53,17 @@ export const formatProjects = (projects) =>
);
export const formatGroups = (groups) =>
groups.map(({ id, webUrl, parent, maxAccessLevel: accessLevel, ...group }) => ({
groups.map(({ id, webUrl, parent, maxAccessLevel: accessLevel, userPermissions, ...group }) => ({
...group,
id: getIdFromGraphQLId(id),
webUrl,
parent: parent?.id || null,
accessLevel,
editPath: `${webUrl}/-/edit`,
availableActions: [ACTION_EDIT, ACTION_DELETE],
availableActions: availableGroupActions(userPermissions),
actionLoadingStates: {
[ACTION_DELETE]: false,
},
}));
export const onPageChange = ({
......
......@@ -56,6 +56,11 @@ export default {
type: String,
required: true,
},
confirmLoading: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return { confirmationPhrase: '' };
......@@ -72,6 +77,7 @@ export default {
attributes: {
variant: 'danger',
disabled: !this.isValid,
loading: this.confirmLoading,
'data-testid': 'confirm-danger-modal-button',
},
};
......@@ -82,6 +88,15 @@ export default {
};
},
},
watch: {
confirmLoading(isLoading, wasLoading) {
// If the button was loading and now no longer is
if (!isLoading && wasLoading) {
// Hide the modal
this.$emit('change', false);
}
},
},
methods: {
equalString(a, b) {
return a.trim().toLowerCase() === b.trim().toLowerCase();
......@@ -105,7 +120,7 @@ export default {
:action-primary="actionPrimary"
:action-cancel="actionCancel"
size="sm"
@primary="$emit('confirm')"
@primary="$emit('confirm', $event)"
@change="$emit('change', $event)"
>
<gl-alert
......
......@@ -99,6 +99,9 @@ export default {
hasActionDelete() {
return this.group.availableActions?.includes(ACTION_DELETE);
},
isActionDeleteLoading() {
return this.group.actionLoadingStates?.[ACTION_DELETE];
},
},
methods: {
onActionDelete() {
......@@ -212,7 +215,8 @@ export default {
v-model="isDeleteModalVisible"
:modal-id="modalId"
:phrase="group.fullName"
@confirm="$emit('delete', group)"
:confirm-loading="isActionDeleteLoading"
@confirm.prevent="$emit('delete', group)"
/>
</li>
</template>
......@@ -5,7 +5,7 @@ module PermissionTypes
class Group < BasePermissionType
graphql_name 'GroupPermissions'
abilities :read_group, :create_projects, :create_custom_emoji
abilities :read_group, :create_projects, :create_custom_emoji, :remove_group
end
end
end
......@@ -21230,6 +21230,7 @@ Returns [`UserMergeRequestInteraction`](#usermergerequestinteraction).
| <a id="grouppermissionscreatecustomemoji"></a>`createCustomEmoji` | [`Boolean!`](#boolean) | If `true`, the user can perform `create_custom_emoji` on this resource. |
| <a id="grouppermissionscreateprojects"></a>`createProjects` | [`Boolean!`](#boolean) | If `true`, the user can perform `create_projects` on this resource. |
| <a id="grouppermissionsreadgroup"></a>`readGroup` | [`Boolean!`](#boolean) | If `true`, the user can perform `read_group` on this resource. |
| <a id="grouppermissionsremovegroup"></a>`removeGroup` | [`Boolean!`](#boolean) | If `true`, the user can perform `remove_group` on this resource. |
 
### `GroupReleaseStats`
 
......@@ -34803,6 +34803,9 @@ msgstr ""
msgid "Organization|An error occurred creating an organization. Please try again."
msgstr ""
 
msgid "Organization|An error occurred deleting the group. Please refresh the page to try again."
msgstr ""
msgid "Organization|An error occurred deleting the project. Please refresh the page to try again."
msgstr ""
 
import MockAdapter from 'axios-mock-adapter';
import getGroupTransferLocationsResponse from 'test_fixtures/api/groups/transfer_locations.json';
import group from 'test_fixtures/api/groups/post.json';
import getGroupTransferLocationsResponse from 'test_fixtures/api/groups/transfer_locations.json';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { DEFAULT_PER_PAGE } from '~/api';
import {
deleteGroup,
updateGroup,
getGroupTransferLocations,
getGroupMembers,
......@@ -47,6 +48,22 @@ describe('GroupsApi', () => {
});
});
describe('deleteGroup', () => {
beforeEach(() => {
jest.spyOn(axios, 'delete');
});
it('deletes to the correct URL', () => {
const expectedUrl = `${mockUrlRoot}/api/${mockApiVersion}/groups/${mockGroupId}`;
mock.onDelete(expectedUrl).replyOnce(HTTP_STATUS_OK);
return deleteGroup(mockGroupId).then(() => {
expect(axios.delete).toHaveBeenCalledWith(expectedUrl);
});
});
});
describe('getGroupTransferLocations', () => {
beforeEach(() => {
jest.spyOn(axios, 'get');
......
......@@ -7,8 +7,10 @@ import NewGroupButton from '~/organizations/shared/components/new_group_button.v
import { formatGroups } from '~/organizations/shared/utils';
import groupsQuery from '~/organizations/shared/graphql/queries/groups.query.graphql';
import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue';
import { ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
import { createAlert } from '~/alert';
import { DEFAULT_PER_PAGE } from '~/api';
import { deleteGroup } from '~/api/groups_api';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -20,6 +22,7 @@ import {
} from '~/organizations/mock_data';
jest.mock('~/alert');
jest.mock('~/api/groups_api');
Vue.use(VueApollo);
......@@ -69,6 +72,11 @@ describe('GroupsView', () => {
const findPagination = () => wrapper.findComponent(GlKeysetPagination);
const findNewGroupButton = () => wrapper.findComponent(NewGroupButton);
const findGroupsList = () => wrapper.findComponent(GroupsList);
const findGroupsListByGroupId = (groupId) =>
findGroupsList()
.props('groups')
.find((group) => group.id === groupId);
afterEach(() => {
mockApollo = null;
......@@ -320,4 +328,77 @@ describe('GroupsView', () => {
});
});
});
describe('Deleting group', () => {
const MOCK_GROUP = formatGroups(nodes)[0];
describe('when API call is successful', () => {
beforeEach(async () => {
deleteGroup.mockResolvedValueOnce(Promise.resolve());
createComponent();
await waitForPromises();
});
it('calls deleteGroup, properly sets loading state, and refetches list when promise resolves', async () => {
findGroupsList().vm.$emit('delete', MOCK_GROUP);
expect(deleteGroup).toHaveBeenCalledWith(MOCK_GROUP.id);
expect(findGroupsListByGroupId(MOCK_GROUP.id).actionLoadingStates[ACTION_DELETE]).toBe(
true,
);
await waitForPromises();
expect(findGroupsListByGroupId(MOCK_GROUP.id).actionLoadingStates[ACTION_DELETE]).toBe(
false,
);
// Refetches list
expect(successHandler).toHaveBeenCalledTimes(2);
});
it('does not call createAlert', async () => {
findGroupsList().vm.$emit('delete', MOCK_GROUP);
await waitForPromises();
expect(createAlert).not.toHaveBeenCalled();
});
});
describe('when API call is not successful', () => {
const error = new Error();
beforeEach(async () => {
deleteGroup.mockRejectedValue(error);
createComponent();
await waitForPromises();
});
it('calls deleteGroup, properly sets loading state, and shows error alert', async () => {
findGroupsList().vm.$emit('delete', MOCK_GROUP);
expect(deleteGroup).toHaveBeenCalledWith(MOCK_GROUP.id);
expect(findGroupsListByGroupId(MOCK_GROUP.id).actionLoadingStates[ACTION_DELETE]).toBe(
true,
);
await waitForPromises();
expect(findGroupsListByGroupId(MOCK_GROUP.id).actionLoadingStates[ACTION_DELETE]).toBe(
false,
);
// Does not refetch list
expect(successHandler).toHaveBeenCalledTimes(1);
expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred deleting the group. Please refresh the page to try again.',
error,
captureError: true,
});
});
});
});
});
......@@ -49,7 +49,7 @@ describe('formatProjects', () => {
});
describe('formatGroups', () => {
it('correctly formats the groups', () => {
it('correctly formats the groups with delete permissions', () => {
const [firstMockGroup] = organizationGroups;
const formattedGroups = formatGroups(organizationGroups);
const [firstFormattedGroup] = formattedGroups;
......@@ -62,7 +62,31 @@ describe('formatGroups', () => {
integerValue: 30,
},
availableActions: [ACTION_EDIT, ACTION_DELETE],
actionLoadingStates: {
[ACTION_DELETE]: false,
},
});
expect(formattedGroups.length).toBe(organizationGroups.length);
});
it('correctly formats the groups without delete permissions', () => {
const nonDeletableGroup = organizationGroups[organizationGroups.length - 1];
const formattedGroups = formatGroups(organizationGroups);
const nonDeletableFormattedGroup = formattedGroups[formattedGroups.length - 1];
expect(nonDeletableFormattedGroup).toMatchObject({
id: getIdFromGraphQLId(nonDeletableGroup.id),
parent: null,
editPath: `${nonDeletableFormattedGroup.webUrl}/-/edit`,
accessLevel: {
integerValue: 30,
},
availableActions: [ACTION_EDIT],
actionLoadingStates: {
[ACTION_DELETE]: false,
},
});
expect(formattedGroups.length).toBe(organizationGroups.length);
});
});
......
import { GlModal, GlSprintf } from '@gitlab/ui';
import { nextTick } from 'vue';
import {
CONFIRM_DANGER_WARNING,
CONFIRM_DANGER_MODAL_BUTTON,
......@@ -26,47 +27,51 @@ describe('Confirm Danger Modal', () => {
const findCancelAction = () => findModal().props('actionCancel');
const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[attr];
const createComponent = ({ provide = {} } = {}) =>
shallowMountExtended(ConfirmDangerModal, {
const createComponent = ({ props = {}, provide = {} } = {}) => {
wrapper = shallowMountExtended(ConfirmDangerModal, {
propsData: {
modalId,
phrase,
visible: false,
...props,
},
provide,
stubs: { GlSprintf },
});
};
beforeEach(() => {
wrapper = createComponent({
provide: { confirmDangerMessage, confirmButtonText, cancelButtonText },
describe('with injected data', () => {
beforeEach(() => {
createComponent({
provide: { confirmDangerMessage, confirmButtonText, cancelButtonText },
});
});
});
it('renders the default warning message', () => {
expect(findDefaultWarning().text()).toBe(CONFIRM_DANGER_WARNING);
});
it('renders the default warning message', () => {
expect(findDefaultWarning().text()).toBe(CONFIRM_DANGER_WARNING);
});
it('renders any additional messages', () => {
expect(findAdditionalMessage().text()).toBe(confirmDangerMessage);
});
it('renders any additional messages', () => {
expect(findAdditionalMessage().text()).toBe(confirmDangerMessage);
});
it('renders the confirm button', () => {
expect(findPrimaryAction().text).toBe(confirmButtonText);
expect(findPrimaryActionAttributes('variant')).toBe('danger');
});
it('renders the confirm button', () => {
expect(findPrimaryAction().text).toBe(confirmButtonText);
expect(findPrimaryActionAttributes('variant')).toBe('danger');
});
it('renders the cancel button', () => {
expect(findCancelAction().text).toBe(cancelButtonText);
});
it('renders the cancel button', () => {
expect(findCancelAction().text).toBe(cancelButtonText);
});
it('renders the correct confirmation phrase', () => {
expect(findConfirmationPhrase().text()).toBe(`Please type ${phrase} to proceed.`);
it('renders the correct confirmation phrase', () => {
expect(findConfirmationPhrase().text()).toBe(`Please type ${phrase} to proceed.`);
});
});
describe('without injected data', () => {
beforeEach(() => {
wrapper = createComponent();
createComponent();
});
it('does not render any additional messages', () => {
......@@ -84,7 +89,7 @@ describe('Confirm Danger Modal', () => {
describe('with a valid confirmation phrase', () => {
beforeEach(() => {
wrapper = createComponent();
createComponent();
});
it('enables the confirm button', async () => {
......@@ -95,17 +100,22 @@ describe('Confirm Danger Modal', () => {
expect(findPrimaryActionAttributes('disabled')).toBe(false);
});
it('emits a `confirm` event when the button is clicked', async () => {
it('emits a `confirm` event with the $event when the button is clicked', async () => {
const MOCK_EVENT = new Event('primaryEvent');
expect(wrapper.emitted('confirm')).toBeUndefined();
await findConfirmationInput().vm.$emit('input', phrase);
await findModal().vm.$emit('primary');
await findModal().vm.$emit('primary', MOCK_EVENT);
expect(wrapper.emitted('confirm')).not.toBeUndefined();
expect(wrapper.emitted('confirm')).toEqual([[MOCK_EVENT]]);
});
});
describe('v-model', () => {
beforeEach(() => {
createComponent();
});
it('emit `change` event', () => {
findModal().vm.$emit('change', true);
......@@ -116,4 +126,21 @@ describe('Confirm Danger Modal', () => {
expect(findModal().props('visible')).toBe(false);
});
});
describe('when confirm loading is true', () => {
beforeEach(() => {
createComponent({ props: { confirmLoading: true } });
});
it('when confirmLoading switches from true to false, emits `change event`', async () => {
// setProps is justified here because we are testing the component's
// reactive behavior which constitutes an exception
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
wrapper.setProps({ confirmLoading: false });
await nextTick();
expect(wrapper.emitted('change')).toEqual([[false]]);
});
});
});
......@@ -214,7 +214,14 @@ describe('GroupsListItem', () => {
describe('when group has actions', () => {
beforeEach(() => {
createComponent();
createComponent({
propsData: {
group: {
...group,
actionLoadingStates: { [ACTION_DELETE]: false },
},
},
});
});
it('displays actions dropdown', () => {
......@@ -240,12 +247,15 @@ describe('GroupsListItem', () => {
expect(findConfirmationModal().props()).toMatchObject({
visible: true,
phrase: group.fullName,
confirmLoading: false,
});
});
describe('when deletion is confirmed', () => {
beforeEach(() => {
findConfirmationModal().vm.$emit('confirm');
findConfirmationModal().vm.$emit('confirm', {
preventDefault: jest.fn(),
});
});
it('emits `delete` event', () => {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment