Commit c3ace554 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '330846-convert-package-list-page-to-use-apollo-graphql' into 'master'

Add delete package to refactored list

See merge request gitlab-org/gitlab!73159
parents 71d50990 ad82e502
......@@ -23,6 +23,7 @@ import PackageFiles from '~/packages_and_registries/package_registry/components/
import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import {
PACKAGE_TYPE_NUGET,
PACKAGE_TYPE_COMPOSER,
......@@ -35,12 +36,10 @@ import {
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
SHOW_DELETE_SUCCESS_ALERT,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
DELETE_PACKAGE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
} from '~/packages_and_registries/package_registry/constants';
import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql';
import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
import Tracking from '~/tracking';
......@@ -62,6 +61,7 @@ export default {
AdditionalMetadata,
InstallationCommands,
PackageFiles,
DeletePackage,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -148,40 +148,15 @@ export default {
formatSize(size) {
return numberToHumanSize(size);
},
async deletePackage() {
const { data } = await this.$apollo.mutate({
mutation: destroyPackageMutation,
variables: {
id: this.packageEntity.id,
},
});
navigateToListWithSuccessModal() {
const returnTo =
!this.groupListUrl || document.referrer.includes(this.projectName)
? this.projectListUrl
: this.groupListUrl; // to avoid security issue url are supplied from backend
if (data?.destroyPackage?.errors[0]) {
throw data.destroyPackage.errors[0];
}
},
async confirmPackageDeletion() {
this.track(DELETE_PACKAGE_TRACKING_ACTION);
try {
await this.deletePackage();
const returnTo =
!this.groupListUrl || document.referrer.includes(this.projectName)
? this.projectListUrl
: this.groupListUrl; // to avoid security issue url are supplied from backend
const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true });
const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true });
window.location.replace(`${returnTo}?${modalQuery}`);
} catch (error) {
createFlash({
message: DELETE_PACKAGE_ERROR_MESSAGE,
type: 'warning',
captureError: true,
error,
});
}
window.location.replace(`${returnTo}?${modalQuery}`);
},
async deletePackageFile(id) {
try {
......@@ -322,26 +297,33 @@ export default {
</gl-tab>
</gl-tabs>
<gl-modal
ref="deleteModal"
modal-id="delete-modal"
data-testid="delete-modal"
:action-primary="$options.modal.packageDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
@primary="confirmPackageDeletion"
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
<delete-package
@start="track($options.trackingActions.DELETE_PACKAGE_TRACKING_ACTION)"
@end="navigateToListWithSuccessModal"
>
<template #modal-title>{{ $options.i18n.deleteModalTitle }}</template>
<gl-sprintf :message="$options.i18n.deleteModalContent">
<template #version>
<strong>{{ packageEntity.version }}</strong>
</template>
<template #default="{ deletePackage }">
<gl-modal
ref="deleteModal"
modal-id="delete-modal"
data-testid="delete-modal"
:action-primary="$options.modal.packageDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
@primary="deletePackage(packageEntity)"
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
>
<template #modal-title>{{ $options.i18n.deleteModalTitle }}</template>
<gl-sprintf :message="$options.i18n.deleteModalContent">
<template #version>
<strong>{{ packageEntity.version }}</strong>
</template>
<template #name>
<strong>{{ packageEntity.name }}</strong>
</template>
</gl-sprintf>
</gl-modal>
<template #name>
<strong>{{ packageEntity.name }}</strong>
</template>
</gl-sprintf>
</gl-modal>
</template>
</delete-package>
<gl-modal
ref="deleteFileModal"
......
<script>
import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql';
import createFlash from '~/flash';
import { s__ } from '~/locale';
export default {
props: {
refetchQueries: {
type: Array,
required: false,
default: null,
},
showSuccessAlert: {
type: Boolean,
required: false,
default: false,
},
},
i18n: {
errorMessage: s__('PackageRegistry|Something went wrong while deleting the package.'),
successMessage: s__('PackageRegistry|Package deleted successfully'),
},
methods: {
async deletePackage(packageEntity) {
try {
this.$emit('start');
const { data } = await this.$apollo.mutate({
mutation: destroyPackageMutation,
variables: {
id: packageEntity.id,
},
awaitRefetchQueries: Boolean(this.refetchQueries),
refetchQueries: this.refetchQueries,
});
if (data?.destroyPackage?.errors[0]) {
throw data.destroyPackage.errors[0];
}
if (this.showSuccessAlert) {
createFlash({
message: this.$options.i18n.successMessage,
type: 'success',
});
}
} catch (error) {
createFlash({
message: this.$options.i18n.errorMessage,
type: 'warning',
captureError: true,
error,
});
}
this.$emit('end');
},
},
render() {
return this.$scopedSlots.default({ deletePackage: this.deletePackage });
},
};
</script>
<script>
/*
* The following component has several commented lines, this is because we are refactoring them piece by piece on several mrs
* For a complete overview of the plan please check: https://gitlab.com/gitlab-org/gitlab/-/issues/330846
* This work is behind feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/341136
*/
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import createFlash from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
import {
PROJECT_RESOURCE_TYPE,
GROUP_RESOURCE_TYPE,
LIST_QUERY_DEBOUNCE_TIME,
GRAPHQL_PAGE_SIZE,
} from '~/packages_and_registries/package_registry/constants';
import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import PackageTitle from './package_title.vue';
import PackageSearch from './package_search.vue';
import PackageList from './packages_list.vue';
......@@ -29,6 +26,7 @@ export default {
PackageList,
PackageTitle,
PackageSearch,
DeletePackage,
},
inject: [
'packageHelpUrl',
......@@ -42,6 +40,7 @@ export default {
packages: {},
sort: '',
filters: {},
mutationLoading: false,
};
},
apollo: {
......@@ -88,6 +87,17 @@ export default {
? this.$options.i18n.emptyPageTitle
: this.$options.i18n.noResultsTitle;
},
isLoading() {
return this.$apollo.queries.packages.loading || this.mutationLoading;
},
refetchQueriesData() {
return [
{
query: getPackagesQuery,
variables: this.queryVariables,
},
];
},
},
mounted() {
this.checkDeleteAlert();
......@@ -153,25 +163,35 @@ export default {
<package-title :help-url="packageHelpUrl" :count="packagesCount" />
<package-search @update="handleSearchUpdate" />
<package-list
:list="packages.nodes"
:is-loading="$apollo.queries.packages.loading"
:page-info="pageInfo"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
<delete-package
:refetch-queries="refetchQueriesData"
show-success-alert
@start="mutationLoading = true"
@end="mutationLoading = false"
>
<template #empty-state>
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
<template #description>
<gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
<gl-sprintf v-else :message="$options.i18n.noResultsText">
<template #noPackagesLink="{ content }">
<gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
<template #default="{ deletePackage }">
<package-list
:list="packages.nodes"
:is-loading="isLoading"
:page-info="pageInfo"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
@package:delete="deletePackage"
>
<template #empty-state>
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
<template #description>
<gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
<gl-sprintf v-else :message="$options.i18n.noResultsText">
<template #noPackagesLink="{ content }">
<gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
</gl-sprintf>
</gl-empty-state>
</template>
</gl-empty-state>
</package-list>
</template>
</package-list>
</delete-package>
</div>
</template>
......@@ -60,21 +60,28 @@ export default {
showPagination() {
return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
},
showDeleteModal: {
get() {
return Boolean(this.itemToBeDeleted);
},
set(value) {
if (!value) {
this.itemToBeDeleted = null;
}
},
},
},
methods: {
setItemToBeDeleted(item) {
this.itemToBeDeleted = { ...item };
this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
this.$refs.packageListDeleteModal.show();
},
deleteItemConfirmation() {
this.$emit('package:delete', this.itemToBeDeleted);
this.track(DELETE_PACKAGE_TRACKING_ACTION);
this.itemToBeDeleted = null;
},
deleteItemCanceled() {
this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION);
this.itemToBeDeleted = null;
},
},
i18n: {
......@@ -115,7 +122,7 @@ export default {
</div>
<gl-modal
ref="packageListDeleteModal"
v-model="showDeleteModal"
modal-id="confirm-delete-pacakge"
ok-variant="danger"
@ok="deleteItemConfirmation"
......
......@@ -60,9 +60,6 @@ export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND =
'copy_composer_package_include_command';
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
);
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package file.',
);
......
......@@ -24512,6 +24512,9 @@ msgstr ""
msgid "PackageRegistry|Package Registry"
msgstr ""
msgid "PackageRegistry|Package deleted successfully"
msgstr ""
msgid "PackageRegistry|Package file deleted successfully"
msgstr ""
......
......@@ -16,16 +16,15 @@ import PackageFiles from '~/packages_and_registries/package_registry/components/
import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import {
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
DELETE_PACKAGE_ERROR_MESSAGE,
PACKAGE_TYPE_COMPOSER,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
PACKAGE_TYPE_NUGET,
} from '~/packages_and_registries/package_registry/constants';
import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql';
import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
import {
......@@ -34,8 +33,6 @@ import {
packageVersions,
dependencyLinks,
emptyPackageDetailsQuery,
packageDestroyMutation,
packageDestroyMutationError,
packageFiles,
packageDestroyFileMutation,
packageDestroyFileMutationError,
......@@ -64,14 +61,12 @@ describe('PackagesApp', () => {
function createComponent({
resolver = jest.fn().mockResolvedValue(packageDetailsQuery()),
mutationResolver = jest.fn().mockResolvedValue(packageDestroyMutation()),
fileDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFileMutation()),
} = {}) {
localVue.use(VueApollo);
const requestHandlers = [
[getPackageDetails, resolver],
[destroyPackageMutation, mutationResolver],
[destroyPackageFileMutation, fileDeleteMutationResolver],
];
apolloProvider = createMockApollo(requestHandlers);
......@@ -82,6 +77,7 @@ describe('PackagesApp', () => {
provide,
stubs: {
PackageTitle,
DeletePackage,
GlModal: {
template: '<div></div>',
methods: {
......@@ -108,6 +104,7 @@ describe('PackagesApp', () => {
const findDependenciesCountBadge = () => wrapper.findComponent(GlBadge);
const findNoDependenciesMessage = () => wrapper.findByTestId('no-dependencies-message');
const findDependencyRows = () => wrapper.findAllComponents(DependencyRow);
const findDeletePackage = () => wrapper.findComponent(DeletePackage);
afterEach(() => {
wrapper.destroy();
......@@ -187,14 +184,6 @@ describe('PackagesApp', () => {
});
};
const performDeletePackage = async () => {
await findDeleteButton().trigger('click');
findDeleteModal().vm.$emit('primary');
await waitForPromises();
};
afterEach(() => {
Object.defineProperty(document, 'referrer', {
value: originalReferrer,
......@@ -220,7 +209,7 @@ describe('PackagesApp', () => {
await waitForPromises();
await performDeletePackage();
findDeletePackage().vm.$emit('end');
expect(window.location.replace).toHaveBeenCalledWith(
'projectListUrl?showSuccessDeleteAlert=true',
......@@ -234,45 +223,13 @@ describe('PackagesApp', () => {
await waitForPromises();
await performDeletePackage();
findDeletePackage().vm.$emit('end');
expect(window.location.replace).toHaveBeenCalledWith(
'groupListUrl?showSuccessDeleteAlert=true',
);
});
});
describe('request failure', () => {
it('on global failure it displays an alert', async () => {
createComponent({ mutationResolver: jest.fn().mockRejectedValue() });
await waitForPromises();
await performDeletePackage();
expect(createFlash).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_ERROR_MESSAGE,
}),
);
});
it('on payload with error it displays an alert', async () => {
createComponent({
mutationResolver: jest.fn().mockResolvedValue(packageDestroyMutationError()),
});
await waitForPromises();
await performDeletePackage();
expect(createFlash).toHaveBeenCalledWith(
expect.objectContaining({
message: DELETE_PACKAGE_ERROR_MESSAGE,
}),
);
});
});
});
describe('package files', () => {
......
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import createFlash from '~/flash';
import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql';
import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
import {
packageDestroyMutation,
packageDestroyMutationError,
packagesListQuery,
} from '../../mock_data';
jest.mock('~/flash');
const localVue = createLocalVue();
describe('DeletePackage', () => {
let wrapper;
let apolloProvider;
let resolver;
let mutationResolver;
const eventPayload = { id: '1' };
function createComponent(propsData = {}) {
localVue.use(VueApollo);
const requestHandlers = [
[getPackagesQuery, resolver],
[destroyPackageMutation, mutationResolver],
];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMountExtended(DeletePackage, {
propsData,
localVue,
apolloProvider,
scopedSlots: {
default(props) {
return this.$createElement('button', {
attrs: {
'data-testid': 'trigger-button',
},
on: {
click: props.deletePackage,
},
});
},
},
});
}
const findButton = () => wrapper.findByTestId('trigger-button');
const clickOnButtonAndWait = (payload) => {
findButton().trigger('click', payload);
return waitForPromises();
};
beforeEach(() => {
resolver = jest.fn().mockResolvedValue(packagesListQuery());
mutationResolver = jest.fn().mockResolvedValue(packageDestroyMutation());
});
afterEach(() => {
wrapper.destroy();
});
it('binds deletePackage method to the default slot', () => {
createComponent();
findButton().trigger('click');
expect(wrapper.emitted('start')).toEqual([[]]);
});
it('calls apollo mutation', async () => {
createComponent();
await clickOnButtonAndWait(eventPayload);
expect(mutationResolver).toHaveBeenCalledWith(eventPayload);
});
it('passes refetchQueries to apollo mutate', async () => {
const variables = { isGroupPage: true };
createComponent({
refetchQueries: [{ query: getPackagesQuery, variables }],
});
await clickOnButtonAndWait(eventPayload);
expect(mutationResolver).toHaveBeenCalledWith(eventPayload);
expect(resolver).toHaveBeenCalledWith(variables);
});
describe('on mutation success', () => {
it('emits end event', async () => {
createComponent();
await clickOnButtonAndWait(eventPayload);
expect(wrapper.emitted('end')).toEqual([[]]);
});
it('does not call createFlash', async () => {
createComponent();
await clickOnButtonAndWait(eventPayload);
expect(createFlash).not.toHaveBeenCalled();
});
it('calls createFlash with the success message when showSuccessAlert is true', async () => {
createComponent({ showSuccessAlert: true });
await clickOnButtonAndWait(eventPayload);
expect(createFlash).toHaveBeenCalledWith({
message: DeletePackage.i18n.successMessage,
type: 'success',
});
});
});
describe.each`
errorType | mutationResolverResponse
${'connectionError'} | ${jest.fn().mockRejectedValue()}
${'localError'} | ${jest.fn().mockResolvedValue(packageDestroyMutationError())}
`('on mutation $errorType', ({ mutationResolverResponse }) => {
beforeEach(() => {
mutationResolver = mutationResolverResponse;
});
it('emits end event', async () => {
createComponent();
await clickOnButtonAndWait(eventPayload);
expect(wrapper.emitted('end')).toEqual([[]]);
});
it('calls createFlash with the error message', async () => {
createComponent({ showSuccessAlert: true });
await clickOnButtonAndWait(eventPayload);
expect(createFlash).toHaveBeenCalledWith({
message: DeletePackage.i18n.errorMessage,
type: 'warning',
captureError: true,
error: expect.any(Error),
});
});
});
});
......@@ -10,6 +10,7 @@ import PackageListApp from '~/packages_and_registries/package_registry/component
import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue';
import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue';
import OriginalPackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue';
import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import {
PROJECT_RESOURCE_TYPE,
......@@ -55,6 +56,7 @@ describe('PackagesListApp', () => {
const findSearch = () => wrapper.findComponent(PackageSearch);
const findListComponent = () => wrapper.findComponent(PackageList);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findDeletePackage = () => wrapper.findComponent(DeletePackage);
const mountComponent = ({
resolver = jest.fn().mockResolvedValue(packagesListQuery()),
......@@ -72,9 +74,10 @@ describe('PackagesListApp', () => {
stubs: {
GlEmptyState,
GlLoadingIcon,
PackageList,
GlSprintf,
GlLink,
PackageList,
DeletePackage,
},
});
};
......@@ -228,4 +231,45 @@ describe('PackagesListApp', () => {
expect(findEmptyState().text()).toContain(PackageListApp.i18n.widenFilters);
});
});
describe('delete package', () => {
it('exists and has the correct props', async () => {
mountComponent();
await waitForDebouncedApollo();
expect(findDeletePackage().props()).toMatchObject({
refetchQueries: [{ query: getPackagesQuery, variables: {} }],
showSuccessAlert: true,
});
});
it('deletePackage is bound to package-list package:delete event', async () => {
mountComponent();
await waitForDebouncedApollo();
findListComponent().vm.$emit('package:delete', { id: 1 });
expect(findDeletePackage().emitted('start')).toEqual([[]]);
});
it('start and end event set loading correctly', async () => {
mountComponent();
await waitForDebouncedApollo();
findDeletePackage().vm.$emit('start');
await nextTick();
expect(findListComponent().props('isLoading')).toBe(true);
findDeletePackage().vm.$emit('end');
await nextTick();
expect(findListComponent().props('isLoading')).toBe(false);
});
});
});
......@@ -122,14 +122,6 @@ describe('packages_list', () => {
expect(findPackageListDeleteModal().text()).toContain(firstPackage.name);
});
it('confirming delete empties itemsToBeDeleted', async () => {
findPackageListDeleteModal().vm.$emit('ok');
await nextTick();
expect(findPackageListDeleteModal().text()).not.toContain(firstPackage.name);
});
it('confirming on the modal emits package:delete', async () => {
findPackageListDeleteModal().vm.$emit('ok');
......@@ -138,8 +130,9 @@ describe('packages_list', () => {
expect(wrapper.emitted('package:delete')[0]).toEqual([firstPackage]);
});
it('cancel event resets itemToBeDeleted', async () => {
findPackageListDeleteModal().vm.$emit('cancel');
it('closing the modal resets itemToBeDeleted', async () => {
// triggering the v-model
findPackageListDeleteModal().vm.$emit('input', false);
await nextTick();
......
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