diff options
Diffstat (limited to 'spec/frontend/packages_and_registries/package_registry')
9 files changed, 606 insertions, 110 deletions
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap index fdddc131412..61923233d2e 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap @@ -29,19 +29,25 @@ exports[`PackageTitle renders with tags 1`] = ` <div class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3" > - <span + <div + class="gl-display-flex gl-gap-3" data-testid="sub-header" > v 1.0.0 published <time-ago-tooltip-stub - class="gl-ml-2" cssclass="" time="2020-08-17T14:23:32Z" tooltipplacement="top" /> - </span> + + <package-tags-stub + hidelabel="true" + tagdisplaylimit="2" + tags="[object Object],[object Object],[object Object]" + /> + </div> </div> </div> </div> @@ -73,15 +79,6 @@ exports[`PackageTitle renders with tags 1`] = ` texttooltip="" /> </div> - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <package-tags-stub - hidelabel="true" - tagdisplaylimit="2" - tags="[object Object],[object Object],[object Object]" - /> - </div> </div> </div> @@ -121,19 +118,21 @@ exports[`PackageTitle renders without tags 1`] = ` <div class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3" > - <span + <div + class="gl-display-flex gl-gap-3" data-testid="sub-header" > v 1.0.0 published <time-ago-tooltip-stub - class="gl-ml-2" cssclass="" time="2020-08-17T14:23:32Z" tooltipplacement="top" /> - </span> + + <!----> + </div> </div> </div> </div> diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap index 06ae8645101..92c2cd90568 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap @@ -2,19 +2,161 @@ exports[`PypiInstallation renders all the messages 1`] = ` <div> - <installation-title-stub - options="[object Object]" - packagetype="pypi" - /> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center" + > + <h3 + class="gl-font-lg" + > + Installation + </h3> + + <div> + <div + class="dropdown b-dropdown gl-new-dropdown btn-group" + id="__BVID__27" + lazy="" + > + <!----> + <button + aria-expanded="false" + aria-haspopup="true" + class="btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle" + id="__BVID__27__BV_toggle_" + type="button" + > + <!----> + + <!----> + + <span + class="gl-new-dropdown-button-text" + > + Show PyPi commands + </span> + + <svg + aria-hidden="true" + class="gl-button-icon dropdown-chevron gl-icon s16" + data-testid="chevron-down-icon" + role="img" + > + <use + href="#chevron-down" + /> + </svg> + </button> + <ul + aria-labelledby="__BVID__27__BV_toggle_" + class="dropdown-menu" + role="menu" + tabindex="-1" + > + <!----> + </ul> + </div> + </div> + </div> - <code-instruction-stub - copytext="Copy Pip command" - data-testid="pip-command" - instruction="pip install @gitlab-org/package-15 --extra-index-url http://__token__:<your_personal_token>@gdk.test:3000/api/v4/projects/1/packages/pypi/simple" - label="Pip Command" - trackingaction="copy_pip_install_command" - trackinglabel="code_instruction" - /> + <fieldset + aria-describedby="installation-pip-command-group__BV_description_" + class="form-group gl-form-group" + id="installation-pip-command-group" + > + <legend + class="bv-no-focus-ring col-form-label pt-0 col-form-label" + id="installation-pip-command-group__BV_label_" + tabindex="-1" + > + + + + <!----> + + <!----> + </legend> + <div + aria-labelledby="installation-pip-command-group__BV_label_" + class="bv-no-focus-ring" + role="group" + tabindex="-1" + > + <div + data-testid="pip-command" + id="installation-pip-command" + > + <label + for="instruction-input_5" + > + Pip Command + </label> + + <div + class="gl-mb-3" + > + <div + class="input-group gl-mb-3" + > + <input + class="form-control gl-font-monospace" + data-testid="instruction-input" + id="instruction-input_5" + readonly="readonly" + type="text" + /> + + <span + class="input-group-append" + data-testid="instruction-button" + > + <button + aria-label="Copy Pip command" + aria-live="polite" + class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon" + data-clipboard-handle-tooltip="false" + data-clipboard-text="pip install @gitlab-org/package-15 --extra-index-url http://__token__:<your_personal_token>@gdk.test:3000/api/v4/projects/1/packages/pypi/simple" + id="clipboard-button-6" + title="Copy Pip command" + type="button" + > + <!----> + + <svg + aria-hidden="true" + class="gl-button-icon gl-icon s16" + data-testid="copy-to-clipboard-icon" + role="img" + > + <use + href="#copy-to-clipboard" + /> + </svg> + + <!----> + </button> + </span> + </div> + </div> + </div> + <!----> + <!----> + <small + class="form-text text-muted" + id="installation-pip-command-group__BV_description_" + tabindex="-1" + > + You will need a + <a + class="gl-link" + data-testid="access-token-link" + href="/help/user/profile/personal_access_tokens" + > + personal access token + </a> + . + </small> + </div> + </fieldset> <h3 class="gl-font-lg" @@ -30,25 +172,33 @@ exports[`PypiInstallation renders all the messages 1`] = ` file. </p> - <code-instruction-stub - copytext="Copy .pypirc content" + <div data-testid="pypi-setup-content" - instruction="[gitlab] + > + <!----> + + <div> + <pre + class="gl-font-monospace" + data-testid="multiline-instruction" + > + [gitlab] repository = http://gdk.test:3000/api/v4/projects/1/packages/pypi username = __token__ -password = <your personal access token>" - label="" - multiline="true" - trackingaction="copy_pypi_setup_command" - trackinglabel="code_instruction" - /> +password = <your personal access token> + </pre> + </div> + </div> For more information on the PyPi registry, - <gl-link-stub + <a + class="gl-link" + data-testid="pypi-docs-link" href="/help/user/packages/pypi_repository/index" + rel="noopener" target="_blank" > see the documentation - </gl-link-stub> + </a> . </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js index 0447ead0830..529a6a22ddf 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js @@ -1,6 +1,6 @@ -import { GlDropdown, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlButton, GlFormCheckbox } from '@gitlab/ui'; import { nextTick } from 'vue'; -import stubChildren from 'helpers/stub_children'; +import { stubComponent } from 'helpers/stub_component'; import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; import { packageFiles as packageFilesMock } from 'jest/packages_and_registries/package_registry/mock_data'; import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; @@ -11,6 +11,7 @@ describe('Package Files', () => { let wrapper; const findAllRows = () => wrapper.findAllByTestId('file-row'); + const findDeleteSelectedButton = () => wrapper.findByTestId('delete-selected'); const findFirstRow = () => extendedWrapper(findAllRows().at(0)); const findSecondRow = () => extendedWrapper(findAllRows().at(1)); const findFirstRowDownloadLink = () => findFirstRow().findByTestId('download-link'); @@ -22,19 +23,27 @@ describe('Package Files', () => { const findActionMenuDelete = () => findFirstActionMenu().findByTestId('delete-file'); const findFirstToggleDetailsButton = () => findFirstRow().findComponent(GlButton); const findFirstRowShaComponent = (id) => wrapper.findByTestId(id); + const findCheckAllCheckbox = () => wrapper.findByTestId('package-files-checkbox-all'); + const findAllRowCheckboxes = () => wrapper.findAllByTestId('package-files-checkbox'); const files = packageFilesMock(); const [file] = files; - const createComponent = ({ packageFiles = [file], canDelete = true } = {}) => { + const createComponent = ({ + packageFiles = [file], + isLoading = false, + canDelete = true, + stubs, + } = {}) => { wrapper = mountExtended(PackageFiles, { propsData: { canDelete, + isLoading, packageFiles, }, stubs: { - ...stubChildren(PackageFiles), - GlTableLite: false, + GlTable: false, + ...stubs, }, }); }; @@ -157,43 +166,170 @@ describe('Package Files', () => { expect(findSecondRowCommitLink().exists()).toBe(false); }); }); + }); - describe('action menu', () => { - describe('when the user can delete', () => { - it('exists', () => { - createComponent(); + describe('action menu', () => { + describe('when the user can delete', () => { + it('exists', () => { + createComponent(); - expect(findFirstActionMenu().exists()).toBe(true); - }); + expect(findFirstActionMenu().exists()).toBe(true); + expect(findFirstActionMenu().props('icon')).toBe('ellipsis_v'); + expect(findFirstActionMenu().props('textSrOnly')).toBe(true); + expect(findFirstActionMenu().props('text')).toMatchInterpolatedText('More actions'); + }); - describe('menu items', () => { - describe('delete file', () => { - it('exists', () => { - createComponent(); + describe('menu items', () => { + describe('delete file', () => { + it('exists', () => { + createComponent(); - expect(findActionMenuDelete().exists()).toBe(true); - }); + expect(findActionMenuDelete().exists()).toBe(true); + }); - it('emits a delete event when clicked', () => { - createComponent(); + it('emits a delete event when clicked', async () => { + createComponent(); - findActionMenuDelete().vm.$emit('click'); + await findActionMenuDelete().trigger('click'); - const [[{ id }]] = wrapper.emitted('delete-file'); - expect(id).toBe(file.id); - }); + const [[items]] = wrapper.emitted('delete-files'); + const [{ id }] = items; + expect(id).toBe(file.id); }); }); }); + }); + + describe('when the user can not delete', () => { + const canDelete = false; + + it('does not exist', () => { + createComponent({ canDelete }); + + expect(findFirstActionMenu().exists()).toBe(false); + }); + }); + }); + + describe('multi select', () => { + describe('when user can delete', () => { + it('delete selected button exists & is disabled', () => { + createComponent(); + + expect(findDeleteSelectedButton().exists()).toBe(true); + expect(findDeleteSelectedButton().text()).toMatchInterpolatedText('Delete selected'); + expect(findDeleteSelectedButton().props('disabled')).toBe(true); + }); + + it('delete selected button exists & is disabled when isLoading prop is true', () => { + createComponent({ isLoading: true }); + + expect(findDeleteSelectedButton().props('disabled')).toBe(true); + }); + + it('checkboxes to select file are visible', () => { + createComponent({ packageFiles: files }); + + expect(findCheckAllCheckbox().exists()).toBe(true); + expect(findAllRowCheckboxes()).toHaveLength(2); + }); + + it('selecting a checkbox enables delete selected button', async () => { + createComponent(); + + const first = findAllRowCheckboxes().at(0); + + await first.setChecked(true); + + expect(findDeleteSelectedButton().props('disabled')).toBe(false); + }); + + describe('select all checkbox', () => { + it('will toggle between selecting all and deselecting all files', async () => { + const getChecked = () => findAllRowCheckboxes().filter((x) => x.element.checked === true); + + createComponent({ packageFiles: files }); + + expect(getChecked()).toHaveLength(0); + + await findCheckAllCheckbox().setChecked(true); - describe('when the user can not delete', () => { - const canDelete = false; + expect(getChecked()).toHaveLength(files.length); - it('does not exist', () => { - createComponent({ canDelete }); + await findCheckAllCheckbox().setChecked(false); - expect(findFirstActionMenu().exists()).toBe(false); + expect(getChecked()).toHaveLength(0); }); + + it('will toggle the indeterminate state when some but not all files are selected', async () => { + const expectIndeterminateState = (state) => + expect(findCheckAllCheckbox().props('indeterminate')).toBe(state); + + createComponent({ + packageFiles: files, + stubs: { GlFormCheckbox: stubComponent(GlFormCheckbox, { props: ['indeterminate'] }) }, + }); + + expectIndeterminateState(false); + + await findSecondRow().trigger('click'); + + expectIndeterminateState(true); + + await findSecondRow().trigger('click'); + + expectIndeterminateState(false); + + findCheckAllCheckbox().trigger('click'); + + expectIndeterminateState(false); + + await findSecondRow().trigger('click'); + + expectIndeterminateState(true); + }); + }); + + it('emits a delete event when selected', async () => { + createComponent(); + + const first = findAllRowCheckboxes().at(0); + + await first.setChecked(true); + + await findDeleteSelectedButton().trigger('click'); + + const [[items]] = wrapper.emitted('delete-files'); + const [{ id }] = items; + expect(id).toBe(file.id); + }); + + it('emits delete event with both items when all are selected', async () => { + createComponent({ packageFiles: files }); + + await findCheckAllCheckbox().setChecked(true); + + await findDeleteSelectedButton().trigger('click'); + + const [[items]] = wrapper.emitted('delete-files'); + expect(items).toHaveLength(2); + }); + }); + + describe('when user cannot delete', () => { + const canDelete = false; + + it('delete selected button does not exist', () => { + createComponent({ canDelete }); + + expect(findDeleteSelectedButton().exists()).toBe(false); + }); + + it('checkboxes to select file are not visible', () => { + createComponent({ packageFiles: files, canDelete }); + + expect(findCheckAllCheckbox().exists()).toBe(false); + expect(findAllRowCheckboxes()).toHaveLength(0); }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js index f4e6d43812d..ec2e833552a 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js @@ -17,6 +17,12 @@ import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import waitForPromises from 'helpers/wait_for_promises'; import getPackagePipelines from '~/packages_and_registries/package_registry/graphql/queries/get_package_pipelines.query.graphql'; +import Tracking from '~/tracking'; +import { + TRACKING_ACTION_CLICK_PIPELINE_LINK, + TRACKING_ACTION_CLICK_COMMIT_LINK, + TRACKING_LABEL_PACKAGE_HISTORY, +} from '~/packages_and_registries/package_registry/constants'; Vue.use(VueApollo); @@ -181,7 +187,6 @@ describe('Package History', () => { it('link', () => { const linkElement = findElementLink(element); const exist = Boolean(link); - expect(linkElement.exists()).toBe(exist); if (exist) { expect(linkElement.attributes('href')).toBe(link); @@ -189,4 +194,29 @@ describe('Package History', () => { }); }, ); + describe('tracking', () => { + let eventSpy; + const category = 'UI::Packages'; + + beforeEach(() => { + mountComponent(); + eventSpy = jest.spyOn(Tracking, 'event'); + }); + + it('clicking pipeline link tracks the right action', () => { + wrapper.vm.trackPipelineClick(); + expect(eventSpy).toHaveBeenCalledWith(category, TRACKING_ACTION_CLICK_PIPELINE_LINK, { + category, + label: TRACKING_LABEL_PACKAGE_HISTORY, + }); + }); + + it('clicking commit link tracks the right action', () => { + wrapper.vm.trackCommitClick(); + expect(eventSpy).toHaveBeenCalledWith(category, TRACKING_ACTION_CLICK_COMMIT_LINK, { + category, + label: TRACKING_LABEL_PACKAGE_HISTORY, + }); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js index d306f7834f0..37416dcd4e7 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js @@ -22,16 +22,21 @@ const packageWithTags = { packageFiles: { nodes: packageFiles() }, }; +const defaultProvide = { + isGroupPage: false, +}; + describe('PackageTitle', () => { let wrapper; - async function createComponent(packageEntity = packageWithTags) { + async function createComponent(packageEntity = packageWithTags, provide = defaultProvide) { wrapper = shallowMountExtended(PackageTitle, { propsData: { packageEntity }, stubs: { TitleArea, GlSprintf, }, + provide, directives: { GlResizeObserver: createMockDirective(), }, @@ -199,11 +204,22 @@ describe('PackageTitle', () => { expect(findPipelineProject().exists()).toBe(false); }); - it('correctly shows the pipeline project if there is one', async () => { + it('does not display the pipeline project on project page even if it exists', async () => { await createComponent({ ...packageData(), pipelines: { nodes: packagePipelines() }, }); + expect(findPipelineProject().exists()).toBe(false); + }); + + it('correctly shows the pipeline project on group page if there is one', async () => { + await createComponent( + { + ...packageData(), + pipelines: { nodes: packagePipelines() }, + }, + { isGroupPage: true }, + ); expect(findPipelineProject().props()).toMatchObject({ text: packagePipelines()[0].project.name, icon: 'review-list', diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js index f2fef6436a6..20acb0872e5 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js @@ -1,9 +1,10 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { GlSprintf } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; import PypiInstallation from '~/packages_and_registries/package_registry/components/details/pypi_installation.vue'; import { + PERSONAL_ACCESS_TOKEN_HELP_URL, PACKAGE_TYPE_PYPI, TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND, TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, @@ -24,11 +25,12 @@ password = <your personal access token>`; const pipCommand = () => wrapper.findByTestId('pip-command'); const setupInstruction = () => wrapper.findByTestId('pypi-setup-content'); + const findAccessTokenLink = () => wrapper.findByTestId('access-token-link'); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - const findSetupDocsLink = () => wrapper.findComponent(GlLink); + const findSetupDocsLink = () => wrapper.findByTestId('pypi-docs-link'); function createComponent() { - wrapper = shallowMountExtended(PypiInstallation, { + wrapper = mountExtended(PypiInstallation, { propsData: { packageEntity, }, @@ -78,6 +80,12 @@ password = <your personal access token>`; }); }); + it('has a link to personal access token docs', () => { + expect(findAccessTokenLink().attributes()).toMatchObject({ + href: PERSONAL_ACCESS_TOKEN_HELP_URL, + }); + }); + it('has a link to the docs', () => { expect(findSetupDocsLink().attributes()).toMatchObject({ href: PYPI_HELP_PATH, diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index c16c09b5326..eb1e76377ff 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -123,7 +123,7 @@ describe('packages_list_row', () => { findDeleteDropdown().vm.$emit('click'); await nextTick(); - expect(wrapper.emitted('packageToDelete')).toBeTruthy(); + expect(wrapper.emitted('packageToDelete')).toHaveLength(1); expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index d40feee582f..22236424e6a 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -141,6 +141,7 @@ export const packageData = (extend) => ({ }); export const conanMetadata = () => ({ + __typename: 'ConanMetadata', id: 'conan-1', packageChannel: 'stable', packageUsername: 'gitlab-org+gitlab-test', @@ -148,9 +149,8 @@ export const conanMetadata = () => ({ recipePath: 'package-8/1.0.0/gitlab-org+gitlab-test/stable', }); -const conanMetadataQuery = () => ({ ...conanMetadata(), __typename: 'ConanMetadata' }); - export const composerMetadata = () => ({ + __typename: 'ComposerMetadata', targetSha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0', composerJson: { license: 'MIT', @@ -158,19 +158,14 @@ export const composerMetadata = () => ({ }, }); -const composerMetadataQuery = () => ({ - ...composerMetadata(), - __typename: 'ComposerMetadata', -}); - export const pypiMetadata = () => ({ + __typename: 'PypiMetadata', id: 'pypi-1', requiredPython: '1.0.0', }); -const pypiMetadataQuery = () => ({ ...pypiMetadata(), __typename: 'PypiMetadata' }); - export const mavenMetadata = () => ({ + __typename: 'MavenMetadata', id: 'maven-1', appName: 'appName', appGroup: 'appGroup', @@ -178,23 +173,20 @@ export const mavenMetadata = () => ({ path: 'path', }); -const mavenMetadataQuery = () => ({ ...mavenMetadata(), __typename: 'MavenMetadata' }); - export const nugetMetadata = () => ({ + __typename: 'NugetMetadata', id: 'nuget-1', iconUrl: 'iconUrl', licenseUrl: 'licenseUrl', projectUrl: 'projectUrl', }); -const nugetMetadataQuery = () => ({ ...nugetMetadata(), __typename: 'NugetMetadata' }); - const packageTypeMetadataQueryMapping = { - CONAN: conanMetadataQuery, - COMPOSER: composerMetadataQuery, - PYPI: pypiMetadataQuery, - MAVEN: mavenMetadataQuery, - NUGET: nugetMetadataQuery, + CONAN: conanMetadata, + COMPOSER: composerMetadata, + PYPI: pypiMetadata, + MAVEN: mavenMetadata, + NUGET: nugetMetadata, }; export const pagination = (extend) => ({ @@ -221,6 +213,7 @@ export const packageDetailsQuery = (extendPackage) => ({ id: '1', path: 'projectPath', name: 'gitlab-test', + fullPath: 'gitlab-test', }, tags: { nodes: packageTags(), @@ -231,6 +224,9 @@ export const packageDetailsQuery = (extendPackage) => ({ __typename: 'PipelineConnection', }, packageFiles: { + pageInfo: { + hasNextPage: true, + }, nodes: packageFiles(), __typename: 'PackageFileConnection', }, @@ -310,16 +306,16 @@ export const packageDestroyMutationError = () => ({ ], }); -export const packageDestroyFileMutation = () => ({ +export const packageDestroyFilesMutation = () => ({ data: { - destroyPackageFile: { + destroyPackageFiles: { errors: [], }, }, }); -export const packageDestroyFileMutationError = () => ({ +export const packageDestroyFilesMutationError = () => ({ data: { - destroyPackageFile: null, + destroyPackageFiles: null, }, errors: [ { @@ -331,7 +327,7 @@ export const packageDestroyFileMutationError = () => ({ column: 3, }, ], - path: ['destroyPackageFile'], + path: ['destroyPackageFiles'], }, ], }); diff --git a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js index 3cadb001c58..de78e6bb87b 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js @@ -22,6 +22,8 @@ import { PACKAGE_TYPE_COMPOSER, DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, + DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, + DELETE_PACKAGE_FILES_ERROR_MESSAGE, PACKAGE_TYPE_NUGET, PACKAGE_TYPE_MAVEN, PACKAGE_TYPE_CONAN, @@ -29,7 +31,7 @@ import { PACKAGE_TYPE_NPM, } from '~/packages_and_registries/package_registry/constants'; -import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql'; +import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql'; import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql'; import { packageDetailsQuery, @@ -38,8 +40,8 @@ import { dependencyLinks, emptyPackageDetailsQuery, packageFiles, - packageDestroyFileMutation, - packageDestroyFileMutationError, + packageDestroyFilesMutation, + packageDestroyFilesMutationError, } from '../mock_data'; jest.mock('~/flash'); @@ -58,6 +60,7 @@ describe('PackagesApp', () => { emptyListIllustration: 'svgPath', projectListUrl: 'projectListUrl', groupListUrl: 'groupListUrl', + isGroupPage: false, breadCrumbState, }; @@ -65,14 +68,14 @@ describe('PackagesApp', () => { function createComponent({ resolver = jest.fn().mockResolvedValue(packageDetailsQuery()), - fileDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFileMutation()), + filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()), routeId = '1', } = {}) { Vue.use(VueApollo); const requestHandlers = [ [getPackageDetails, resolver], - [destroyPackageFileMutation, fileDeleteMutationResolver], + [destroyPackageFilesMutation, filesDeleteMutationResolver], ]; apolloProvider = createMockApollo(requestHandlers); @@ -110,6 +113,7 @@ describe('PackagesApp', () => { const findDeleteButton = () => wrapper.findByTestId('delete-package'); const findPackageFiles = () => wrapper.findComponent(PackageFiles); const findDeleteFileModal = () => wrapper.findByTestId('delete-file-modal'); + const findDeleteFilesModal = () => wrapper.findByTestId('delete-files-modal'); const findVersionRows = () => wrapper.findAllComponents(VersionRow); const noVersionsMessage = () => wrapper.findByTestId('no-versions-message'); const findDependenciesCountBadge = () => wrapper.findComponent(GlBadge); @@ -288,6 +292,7 @@ describe('PackagesApp', () => { expect(findPackageFiles().props('packageFiles')[0]).toMatchObject(expectedFile); expect(findPackageFiles().props('canDelete')).toBe(packageData().canDestroy); + expect(findPackageFiles().props('isLoading')).toEqual(false); }); it('does not render the package files table when the package is composer', async () => { @@ -305,24 +310,69 @@ describe('PackagesApp', () => { describe('deleting a file', () => { const [fileToDelete] = packageFiles(); - const doDeleteFile = () => { - findPackageFiles().vm.$emit('delete-file', fileToDelete); + const doDeleteFile = async () => { + findPackageFiles().vm.$emit('delete-files', [fileToDelete]); findDeleteFileModal().vm.$emit('primary'); return waitForPromises(); }; - it('opens a confirmation modal', async () => { + it('opens delete file confirmation modal', async () => { createComponent(); await waitForPromises(); - findPackageFiles().vm.$emit('delete-file', fileToDelete); + const showDeleteFileSpy = jest.spyOn(wrapper.vm.$refs.deleteFileModal, 'show'); + const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show'); + + findPackageFiles().vm.$emit('delete-files', [fileToDelete]); + + expect(showDeletePackageSpy).not.toBeCalled(); + expect(showDeleteFileSpy).toBeCalled(); + }); + + it('when its the only file opens delete package confirmation modal', async () => { + const [packageFile] = packageFiles(); + const resolver = jest.fn().mockResolvedValue( + packageDetailsQuery({ + packageFiles: { + pageInfo: { + hasNextPage: false, + }, + nodes: [packageFile], + __typename: 'PackageFileConnection', + }, + }), + ); + + createComponent({ + resolver, + }); + + await waitForPromises(); + + const showDeleteFileSpy = jest.spyOn(wrapper.vm.$refs.deleteFileModal, 'show'); + const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show'); + + findPackageFiles().vm.$emit('delete-files', [fileToDelete]); + + expect(showDeletePackageSpy).toBeCalled(); + expect(showDeleteFileSpy).not.toBeCalled(); + }); + + it('confirming on the modal sets the loading state', async () => { + createComponent(); + + await waitForPromises(); + + findPackageFiles().vm.$emit('delete-files', [fileToDelete]); + + findDeleteFileModal().vm.$emit('primary'); await nextTick(); - expect(findDeleteFileModal().exists()).toBe(true); + expect(findPackageFiles().props('isLoading')).toEqual(true); }); it('confirming on the modal deletes the file and shows a success message', async () => { @@ -344,7 +394,7 @@ describe('PackagesApp', () => { describe('errors', () => { it('shows an error when the mutation request fails', async () => { - createComponent({ fileDeleteMutationResolver: jest.fn().mockRejectedValue() }); + createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() }); await waitForPromises(); await doDeleteFile(); @@ -358,9 +408,9 @@ describe('PackagesApp', () => { it('shows an error when the mutation request returns an error payload', async () => { createComponent({ - fileDeleteMutationResolver: jest + filesDeleteMutationResolver: jest .fn() - .mockResolvedValue(packageDestroyFileMutationError()), + .mockResolvedValue(packageDestroyFilesMutationError()), }); await waitForPromises(); @@ -374,6 +424,117 @@ describe('PackagesApp', () => { }); }); }); + + describe('deleting multiple files', () => { + const doDeleteFiles = async () => { + findPackageFiles().vm.$emit('delete-files', packageFiles()); + + findDeleteFilesModal().vm.$emit('primary'); + + return waitForPromises(); + }; + + it('opens delete files confirmation modal', async () => { + createComponent(); + + await waitForPromises(); + + const showDeleteFilesSpy = jest.spyOn(wrapper.vm.$refs.deleteFilesModal, 'show'); + + findPackageFiles().vm.$emit('delete-files', packageFiles()); + + expect(showDeleteFilesSpy).toBeCalled(); + }); + + it('confirming on the modal sets the loading state', async () => { + createComponent(); + + await waitForPromises(); + + findPackageFiles().vm.$emit('delete-files', packageFiles()); + + findDeleteFilesModal().vm.$emit('primary'); + + await nextTick(); + + expect(findPackageFiles().props('isLoading')).toEqual(true); + }); + + it('confirming on the modal deletes the file and shows a success message', async () => { + const resolver = jest.fn().mockResolvedValue(packageDetailsQuery()); + createComponent({ resolver }); + + await waitForPromises(); + + await doDeleteFiles(); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, + }), + ); + // we are re-fetching the package details, so we expect the resolver to have been called twice + expect(resolver).toHaveBeenCalledTimes(2); + }); + + describe('errors', () => { + it('shows an error when the mutation request fails', async () => { + createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() }); + await waitForPromises(); + + await doDeleteFiles(); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILES_ERROR_MESSAGE, + }), + ); + }); + + it('shows an error when the mutation request returns an error payload', async () => { + createComponent({ + filesDeleteMutationResolver: jest + .fn() + .mockResolvedValue(packageDestroyFilesMutationError()), + }); + await waitForPromises(); + + await doDeleteFiles(); + + expect(createFlash).toHaveBeenCalledWith( + expect.objectContaining({ + message: DELETE_PACKAGE_FILES_ERROR_MESSAGE, + }), + ); + }); + }); + }); + + describe('deleting all files', () => { + it('opens the delete package confirmation modal', async () => { + const resolver = jest.fn().mockResolvedValue( + packageDetailsQuery({ + packageFiles: { + pageInfo: { + hasNextPage: false, + }, + nodes: packageFiles(), + }, + }), + ); + createComponent({ + resolver, + }); + + await waitForPromises(); + + const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show'); + + findPackageFiles().vm.$emit('delete-files', packageFiles()); + + expect(showDeletePackageSpy).toBeCalled(); + }); + }); }); describe('versions', () => { |