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