Commit bcd6726a authored by Steve Abrams's avatar Steve Abrams

Disable image deletion during registry migration

When a container repository is actively importing
the deletion option is disabled.

Changelog: changed
parent 8c63ebd9
<script>
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
import { GlButton, GlLink, GlTooltip, GlSprintf } from '@gitlab/ui';
export default {
name: 'DeleteButton',
components: {
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
GlLink,
GlTooltip,
GlSprintf,
},
props: {
title: {
......@@ -18,6 +18,11 @@ export default {
type: String,
required: true,
},
tooltipLink: {
type: String,
default: '',
required: false,
},
disabled: {
type: Boolean,
default: false,
......@@ -29,21 +34,12 @@ export default {
required: false,
},
},
computed: {
tooltipConfiguration() {
return {
disabled: this.tooltipDisabled,
title: this.tooltipTitle,
};
},
},
};
</script>
<template>
<div v-gl-tooltip="tooltipConfiguration">
<div ref="deleteImageButton">
<gl-button
v-gl-tooltip
:disabled="disabled"
:title="title"
:aria-label="title"
......@@ -52,5 +48,14 @@ export default {
icon="remove"
@click="$emit('delete')"
/>
<gl-tooltip :target="() => $refs.deleteImageButton" :disabled="tooltipDisabled" placement="top">
<gl-sprintf :message="tooltipTitle">
<template #docLink="{ content }">
<gl-link v-if="tooltipLink" :href="tooltipLink" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-tooltip>
</div>
</template>
......@@ -8,11 +8,13 @@ import ListItem from '~/vue_shared/components/registry/list_item.vue';
import {
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
LIST_DELETE_BUTTON_DISABLED,
LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION,
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
IMAGE_MIGRATING_STATE,
ROOT_IMAGE_TEXT,
} from '../../constants/index';
import DeleteButton from '../delete_button.vue';
......@@ -32,6 +34,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['config'],
props: {
item: {
type: Object,
......@@ -44,13 +47,12 @@ export default {
},
},
i18n: {
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
},
computed: {
disabledDelete() {
return !this.item.canDelete || this.deleting;
return !this.item.canDelete || this.deleting || this.migrating;
},
id() {
return getIdFromGraphQLId(this.item.id);
......@@ -58,6 +60,9 @@ export default {
deleting() {
return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS;
},
migrating() {
return this.item.migrationState === IMAGE_MIGRATING_STATE;
},
failedDelete() {
return this.item.status === IMAGE_FAILED_DELETED_STATUS;
},
......@@ -83,6 +88,11 @@ export default {
routerLinkEvent() {
return this.deleting ? '' : 'click';
},
deleteButtonTooltipTitle() {
return this.migrating
? LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION
: LIST_DELETE_BUTTON_DISABLED;
},
},
};
</script>
......@@ -144,8 +154,9 @@ export default {
<delete-button
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:disabled="disabledDelete"
:tooltip-disabled="item.canDelete"
:tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
:tooltip-disabled="!disabledDelete"
:tooltip-link="config.containerRegistryImportingHelpPagePath"
:tooltip-title="deleteButtonTooltipTitle"
@delete="$emit('delete', item)"
/>
</template>
......
......@@ -14,6 +14,9 @@ export const LIST_INTRO_TEXT = s__(
export const LIST_DELETE_BUTTON_DISABLED = s__(
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
);
export const LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION = s__(
`ContainerRegistry|Image repository temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}`,
);
export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository');
export const REMOVE_REPOSITORY_MODAL_TEXT = s__(
'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
......@@ -45,6 +48,7 @@ export const EMPTY_RESULT_MESSAGE = s__(
export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED';
export const IMAGE_MIGRATING_STATE = 'importing';
export const GRAPHQL_PAGE_SIZE = 10;
export const SORT_FIELDS = [
......
......@@ -23,6 +23,7 @@ query getProjectContainerRepositories(
__typename
nodes {
id
migrationState
name
path
status
......@@ -57,6 +58,7 @@ query getProjectContainerRepositories(
__typename
nodes {
id
migrationState
name
path
status
......
......@@ -14,6 +14,7 @@ module Types
field :expiration_policy_started_at, Types::TimeType, null: true, description: 'Timestamp when the cleanup done by the expiration policy was started on the container repository.'
field :id, GraphQL::Types::ID, null: false, description: 'ID of the container repository.'
field :location, GraphQL::Types::String, null: false, description: 'URL of the container repository.'
field :migration_state, GraphQL::Types::String, null: false, description: 'Migration state of the container repository.'
field :name, GraphQL::Types::String, null: false, description: 'Name of the container repository.'
field :path, GraphQL::Types::String, null: false, description: 'Path of the container repository.'
field :project, Types::ProjectType, null: false, description: 'Project of the container registry.'
......
......@@ -9773,6 +9773,7 @@ A container repository.
| <a id="containerrepositoryexpirationpolicystartedat"></a>`expirationPolicyStartedAt` | [`Time`](#time) | Timestamp when the cleanup done by the expiration policy was started on the container repository. |
| <a id="containerrepositoryid"></a>`id` | [`ID!`](#id) | ID of the container repository. |
| <a id="containerrepositorylocation"></a>`location` | [`String!`](#string) | URL of the container repository. |
| <a id="containerrepositorymigrationstate"></a>`migrationState` | [`String!`](#string) | Migration state of the container repository. |
| <a id="containerrepositoryname"></a>`name` | [`String!`](#string) | Name of the container repository. |
| <a id="containerrepositorypath"></a>`path` | [`String!`](#string) | Path of the container repository. |
| <a id="containerrepositoryproject"></a>`project` | [`Project!`](#project) | Project of the container registry. |
......@@ -9794,6 +9795,7 @@ Details of a container repository.
| <a id="containerrepositorydetailsexpirationpolicystartedat"></a>`expirationPolicyStartedAt` | [`Time`](#time) | Timestamp when the cleanup done by the expiration policy was started on the container repository. |
| <a id="containerrepositorydetailsid"></a>`id` | [`ID!`](#id) | ID of the container repository. |
| <a id="containerrepositorydetailslocation"></a>`location` | [`String!`](#string) | URL of the container repository. |
| <a id="containerrepositorydetailsmigrationstate"></a>`migrationState` | [`String!`](#string) | Migration state of the container repository. |
| <a id="containerrepositorydetailsname"></a>`name` | [`String!`](#string) | Name of the container repository. |
| <a id="containerrepositorydetailspath"></a>`path` | [`String!`](#string) | Path of the container repository. |
| <a id="containerrepositorydetailsproject"></a>`project` | [`Project!`](#project) | Project of the container registry. |
......@@ -9758,6 +9758,9 @@ msgstr ""
msgid "ContainerRegistry|Image repository not found"
msgstr ""
msgid "ContainerRegistry|Image repository temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}"
msgstr ""
msgid "ContainerRegistry|Image repository will be deleted"
msgstr ""
......
import { GlButton } from '@gitlab/ui';
import { GlButton, GlTooltip, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import component from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue';
import { LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION } from '~/packages_and_registries/container_registry/explorer/constants/list';
describe('delete_button', () => {
let wrapper;
......@@ -12,6 +12,7 @@ describe('delete_button', () => {
};
const findButton = () => wrapper.find(GlButton);
const findTooltip = () => wrapper.find(GlTooltip);
const mountComponent = (props) => {
wrapper = shallowMount(component, {
......@@ -19,8 +20,9 @@ describe('delete_button', () => {
...defaultProps,
...props,
},
directives: {
GlTooltip: createMockDirective(),
stubs: {
GlTooltip,
GlSprintf,
},
});
};
......@@ -33,41 +35,50 @@ describe('delete_button', () => {
describe('tooltip', () => {
it('the title is controlled by tooltipTitle prop', () => {
mountComponent();
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
const tooltip = findTooltip();
expect(tooltip).toBeDefined();
expect(tooltip.value.title).toBe(defaultProps.tooltipTitle);
expect(tooltip.text()).toBe(defaultProps.tooltipTitle);
});
it('is disabled when tooltipTitle is disabled', () => {
mountComponent({ tooltipDisabled: true });
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip.value.disabled).toBe(true);
expect(findTooltip().props('disabled')).toBe(true);
});
describe('button', () => {
it('exists', () => {
mountComponent();
expect(findButton().exists()).toBe(true);
it('works with a link', () => {
mountComponent({
tooltipTitle: LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION,
tooltipLink: 'foo',
});
expect(findTooltip().text()).toMatchInterpolatedText(
LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION,
);
});
});
it('has the correct props/attributes bound', () => {
mountComponent({ disabled: true });
expect(findButton().attributes()).toMatchObject({
'aria-label': 'Foo title',
icon: 'remove',
title: 'Foo title',
variant: 'danger',
disabled: 'true',
category: 'secondary',
});
});
describe('button', () => {
it('exists', () => {
mountComponent();
expect(findButton().exists()).toBe(true);
});
it('emits a delete event', () => {
mountComponent();
expect(wrapper.emitted('delete')).toEqual(undefined);
findButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[]]);
it('has the correct props/attributes bound', () => {
mountComponent({ disabled: true });
expect(findButton().attributes()).toMatchObject({
'aria-label': 'Foo title',
icon: 'remove',
title: 'Foo title',
variant: 'danger',
disabled: 'true',
category: 'secondary',
});
});
it('emits a delete event', () => {
mountComponent();
expect(wrapper.emitted('delete')).toEqual(undefined);
findButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[]]);
});
});
});
......@@ -10,6 +10,7 @@ import {
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_MIGRATING_STATE,
SCHEDULED_STATUS,
ROOT_IMAGE_TEXT,
} from '~/packages_and_registries/container_registry/explorer/constants';
......@@ -41,6 +42,9 @@ describe('Image List Row', () => {
item,
...props,
},
provide: {
config: {},
},
directives: {
GlTooltip: createMockDirective(),
},
......@@ -178,6 +182,12 @@ describe('Image List Row', () => {
expect(findDeleteBtn().props('disabled')).toBe(state);
},
);
it('is disabled when migrationState is importing', () => {
mountComponent({ item: { ...item, migrationState: IMAGE_MIGRATING_STATE } });
expect(findDeleteBtn().props('disabled')).toBe(true);
});
});
describe('tags count', () => {
......
......@@ -5,6 +5,7 @@ export const imagesListResponse = [
name: 'rails-12009',
path: 'gitlab-org/gitlab-test/rails-12009',
status: null,
migrationState: 'default',
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009',
canDelete: true,
createdAt: '2020-11-03T13:29:21Z',
......@@ -17,6 +18,7 @@ export const imagesListResponse = [
name: 'rails-20572',
path: 'gitlab-org/gitlab-test/rails-20572',
status: null,
migrationState: 'default',
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572',
canDelete: true,
createdAt: '2020-09-21T06:57:43Z',
......
......@@ -3,7 +3,9 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerRepositoryDetails'] do
fields = %i[id name path location created_at updated_at expiration_policy_started_at status tags_count can_delete expiration_policy_cleanup_status tags size project]
fields = %i[id name path location created_at updated_at expiration_policy_started_at
status tags_count can_delete expiration_policy_cleanup_status tags size
project migration_state]
it { expect(described_class.graphql_name).to eq('ContainerRepositoryDetails') }
......
......@@ -3,7 +3,9 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerRepository'] do
fields = %i[id name path location created_at updated_at expiration_policy_started_at status tags_count can_delete expiration_policy_cleanup_status project]
fields = %i[id name path location created_at updated_at expiration_policy_started_at
status tags_count can_delete expiration_policy_cleanup_status project
migration_state]
it { expect(described_class.graphql_name).to eq('ContainerRepository') }
......
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